Feature/#207: Restore actionable Daiso pickup answer via selPkupStr fallback (#215)

* Restore actionable Daiso pickup answer when store pickup stock is blocked

Adds a public selPkupStr-backed getStorePickupEligibility() helper plus a
new pickupEligibility field on lookupStoreProductAvailability(). When
selStrPkupStck still returns 401/403 Unauthorized as in #207, the package
now reports whether the selected store is registered as a pickup-capable
store for the product (pickupEligible: true|false|null), instead of only
returning blocked/unknown.

Closes #207

* Make scope limits explicit in skill description and feature doc

Clarify across three high-traffic surfaces that this skill no longer
returns exact per-store stock quantities while the official Daiso
selStrPkupStck endpoint stays Unauthorized: only pickup eligibility
(yes/no) is reported in that state.

- daiso-product-search/SKILL.md frontmatter description rewritten
  so coding agents see the limit before triggering the skill
- daiso-product-search/SKILL.md adds explicit Scope and limits
  section plus reworked When to use / When not to use examples
- docs/features/daiso-product-search.md adds a new
  "이 기능으로 할 수 없는 일" section listing the quantity gap
- root README.md row clarifies the skill answers pickup eligibility,
  not exact per-store quantities, while the upstream block holds

* Prevent under-scoped Daiso pickup negatives

Return an explicit insufficient-coverage eligibility state when selPkupStr search input cannot prove absence, and require pkupYn=Y for positive eligibility. This preserves the actionable fallback while avoiding false negatives from broad or missing store keywords.

Constraint: Existing PR #215 already added selPkupStr fallback; this follow-up is limited to review-requested correctness fixes.

Rejected: Treating a missing first-page match as definitive false | broad or unkeyed selPkupStr searches can miss the target store.

Confidence: high

Scope-risk: narrow

Directive: Do not claim pickup ineligibility unless the searched selPkupStr coverage is sufficient to prove absence.

Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, and negative 99999.

Not-tested: Exhaustive multi-page live pagination across all Daiso store keywords.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Keep Daiso pickup fallback shape actionable

Stabilize blocked pickupEligibility responses with matchedStore:null and keep optional online-stock failures from preventing the selPkupStr pickup-eligibility fallback. This preserves the core store/product/pickup answer even when reference-only online stock is unavailable.

Constraint: Issue #207 requires an actionable pickup answer when the pickup-stock endpoint is blocked, and PR review required stable public response shape.

Rejected: Letting optional online stock reject the end-to-end helper | it can defeat the new actionable fallback even though online stock is reference-only.

Confidence: high

Scope-risk: narrow

Directive: Keep quantity-bearing pickupStock separate from quantity-free pickupEligibility, and do not let optional enrichments block core pickup fallback results.

Tested: npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live Daiso smoke for 10224, missing keyword, negative 99999, and end-to-end lookup.

Not-tested: Exhaustive live multi-page selPkupStr pagination across every store keyword.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-08 15:41:08 +09:00 committed by GitHub
commit af55f58cb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 682 additions and 20 deletions

View file

@ -0,0 +1,5 @@
---
"daiso-product-search": minor
---
Restore actionable Daiso pickup answers when store pickup stock is blocked by adding a `selPkupStr`-backed `getStorePickupEligibility()` helper plus `pickupEligibility` field on `lookupStoreProductAvailability()`. When pickup stock returns `Unauthorized`, the package now reports whether the selected store is registered as a pickup-capable store for the product instead of only saying "unknown".

View file

@ -67,7 +67,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 올라포케 역삼 포케 | `hola-poke-yeoksam` | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |

View file

@ -1,6 +1,6 @@
---
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.
description: Look up Daiso products by store name and product keyword using official Daiso Mall store/search/stock surfaces. Reports whether a product is registered as pickup-eligible at a specific Daiso store; the official store-level pickup quantity API has been blocked since 2026-05-05, so exact per-store stock counts are unavailable while that block remains.
license: MIT
metadata:
category: retail
@ -18,20 +18,30 @@ metadata:
- 공식 상품 검색으로 상품 후보를 찾는다.
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
- 다이소몰이 매장 픽업 재고 표면을 `Unauthorized` 로 차단하면 차단 상태를 그대로 보고하고 세션 우회는 시도하지 않는다.
- 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 해당 매장에 상품이 픽업 가능 매장으로 등록되어 있는지 여부를 확인해 `pickupEligibility` 로 답한다. 정확한 수량은 여전히 알 수 없다.
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**
## When to use
- "강남역2호점에 리들샷 있어?"
- "다이소 특정 매장 재고 확인해줘"
- "이 상품 어느 매장에 있는지 확인해줘"
- "다이소 매장명 주면 그 매장 재고 봐줘"
- "강남역2호점에서 리들샷 픽업 가능해?" (픽업 가능 여부 확인)
- "이 상품 어느 매장에서 픽업 가능한지 확인해줘" (픽업 가능 매장 목록)
- "다이소 매장명 주면 그 매장에서 살 수 있는지 봐줘"
- 공식 매장 픽업 재고 API 가 응답하면 수량까지, 차단되면 픽업 가능 여부(yes/no)까지
## When not to use
- **"강남역2호점에 리들샷 몇 개 있어?"** 처럼 정확한 재고 수량을 보장해야 하는 경우 — 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized` 로 차단되어 수량을 답할 수 없다.
- 매장명도 상품명도 전혀 없는 상태에서 바로 재고를 단정해야 하는 경우
- 결제/주문/픽업 예약까지 자동화해야 하는 경우
- 비공식 크롤링 결과를 우선해야 하는 경우
- 매장 내 진열 위치(aisle/매대)를 알려줘야 하는 경우
- 비공식 크롤링·세션 우회·계정 로그인 우회 결과를 사용해야 하는 경우
## Scope and limits (must read before answering)
- `pickupStock``retrievalStatus: "resolved"` 로 응답하면 정확한 매장 픽업 재고 수량을 줄 수 있다.
- `pickupStock``retrievalStatus: "blocked"` 면 수량은 더 이상 답하지 않는다. `pickupEligibility.pickupEligible` 로 그 매장에서 픽업 가능한 상품인지(yes/no)만 답한다.
- `onlineStock``referenceOnly: true` 다이소몰 온라인몰 재고 참고값일 뿐 매장 재고가 아니다. 매장 재고처럼 단정하지 않는다.
- 차단 우회는 시도하지 않는다.
## Prerequisites
@ -64,6 +74,7 @@ metadata:
- 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`
- store pickup eligibility (pickup-capable stores for a product): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- optional online stock cross-check: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## Workflow
@ -122,7 +133,25 @@ console.log(stock)
// 차단 예시: { status: "unavailable", retrievalStatus: "blocked", inventoryStatus: "unknown", reason: "unauthorized", quantity: null, inStock: null }
```
### 4. Use the end-to-end helper when both names are already known
### 4. Fall back to pickup eligibility when stock is blocked
매장 픽업 재고가 `Unauthorized` 로 차단되면 공식 픽업 가능 매장 목록 표면으로 **해당 매장이 그 상품의 픽업 가능 매장에 들어 있는지** 만이라도 확인할 수 있다. 수량은 알 수 없지만 "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있다.
```js
const { getStorePickupEligibility } = require("daiso-product-search")
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
console.log(eligibility)
```
`pickupEligible``true` 이면 그 매장에서 픽업 가능, `false` 면 픽업 불가, `null` 이면 확인 불가다. `false` 는 검색 범위가 충분할 때만 확정값으로 해석한다. `retrievalStatus: "insufficient_coverage"` 는 매장명/키워드가 없거나 첫 페이지가 전체 결과를 덮지 못해 부재를 증명하지 못했다는 뜻이다. `eligibleStoreCount``eligibleStores` 로 다른 후보 매장도 함께 보여줄 수 있다.
### 5. Use the end-to-end helper when both names are already known
```js
const { lookupStoreProductAvailability } = require("daiso-product-search")
@ -135,15 +164,19 @@ const result = await lookupStoreProductAvailability({
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
console.log(result.pickupEligibility)
```
### 5. Respond conservatively
`pickupStock.retrievalStatus === "blocked"` 일 때만 `pickupEligibility` 가 채워진다. `includePickupEligibility: false` 옵션으로 끌 수 있다.
### 6. Respond conservatively
응답은 짧고 명확하게 정리한다.
- 매장명
- 상품명
- 매장 재고 수량, 재고 없음, 또는 `retrievalStatus: "blocked"` / `Unauthorized` 로 인한 확인 불가
- 픽업 재고가 차단된 경우 `pickupEligibility.pickupEligible` 로 그 매장의 픽업 가능 여부만이라도 표시
- 필요하면 `referenceOnly: true` 로 표시된 온라인 재고 참고값
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**

View file

@ -4,8 +4,17 @@
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고** 확인
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
- 특정 매장의 **매장 픽업 재고 수량** 확인 (공식 `selStrPkupStck` 표면이 응답할 때 한정)
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 공식 픽업 가능 매장 목록(`selPkupStr`)으로 그 매장의 **픽업 가능 여부(yes/no)** 만이라도 `pickupEligibility` 로 확인
- 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
## 이 기능으로 할 수 없는 일 (스킬 범위 한계)
- **`selStrPkupStck` 가 차단된 동안에는 정확한 매장별 재고 수량을 답할 수 없습니다.** 2026-05-05 부터 공식 매장 픽업 재고 API 가 `Unauthorized (401/403)` 로 차단되어 있고, 이 스킬은 세션 우회·CAPTCHA 우회·로그인 강제 등 anti-bot 우회를 시도하지 않습니다.
- 차단 상태에서는 `pickupEligibility.pickupEligible` 로 "그 매장이 그 상품의 픽업 가능 매장으로 등록되어 있는지(yes/no)" 까지만 답합니다. **수량(예: "3개 남음")은 답하지 않습니다.**
- 매장 내 진열 위치(aisle/매대)는 공식 표면이 제공하지 않으므로 답하지 않습니다.
- 결제·주문·픽업 예약 자동화는 범위가 아닙니다.
- 비공식 크롤링·헤드리스 브라우저 우회·계정 세션 재사용은 범위가 아닙니다.
## 먼저 필요한 것
@ -28,6 +37,7 @@
- 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`
- store pickup eligibility (특정 상품의 픽업 가능 매장 목록): `https://www.daisomall.co.kr/api/ms/msg/selPkupStr`
- optional online stock: `https://www.daisomall.co.kr/api/pdo/selOnlStck`
## 기본 흐름
@ -38,8 +48,9 @@
4. `SearchGoods` 로 상품 후보를 찾습니다.
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
6. `selStrPkupStck``Unauthorized` 로 차단되면 매장 픽업 재고는 `unavailable/blocked/unauthorized` 로 보고하고 세션 우회를 시도하지 않습니다.
7. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
8. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
7. 6번 차단이 발생하면 공식 `selPkupStr` 표면으로 그 상품의 **픽업 가능 매장 목록**을 받아 사용자가 고른 매장이 그 안에 들어 있는지(=`pickupEligibility.pickupEligible`) 만이라도 답합니다. 수량은 여전히 알 수 없습니다.
8. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
9. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
## 예시
@ -56,6 +67,7 @@ async function main() {
store: result.selectedStore,
product: result.selectedProduct,
pickupStock: result.pickupStock,
pickupEligibility: result.pickupEligibility,
onlineStock: result.onlineStock
})
}
@ -74,6 +86,7 @@ main().catch((error) => {
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 상품 재고 여부는 `inStock` 또는 `inventoryStatus` 로 설명하고, `status: "available"` 만으로 재고가 있다고 말하지 않습니다.
- 매장 픽업 재고가 `Unauthorized` 로 차단된 경우에는 `다이소몰이 현재 매장 픽업 재고 API를 차단해 정확한 매장 재고 수량은 확인할 수 없다`고 답하고, 결과의 `retrievalStatus: "blocked"` 와 온라인 재고의 `referenceOnly: true` 참고값을 구분합니다.
- 픽업 재고가 차단되어도 `pickupEligibility.pickupEligible === true``이 상품은 해당 매장의 픽업 가능 매장 목록에 등록되어 있어 픽업 자체는 가능합니다. 다만 정확한 수량은 확인할 수 없습니다.` 정도로 보수적으로 답합니다. `pickupEligible === false``해당 매장은 이 상품의 픽업 가능 매장에 등록되어 있지 않습니다.` 라고 답합니다. `null` 이면 차단 또는 `insufficient_coverage` 로 확인 불가로 답하고, 특히 검색 키워드가 없거나 첫 페이지가 전체 결과를 덮지 못한 경우에는 불가로 단정하지 않습니다.
## 라이브 확인 메모
@ -85,4 +98,5 @@ main().catch((error) => {
- `GET /ssn/search/SearchGoods?searchTerm=...` → 상품 후보 및 `onldPdNo` 확인
- `POST /api/pd/pdh/selStrPkupStck` → 성공하면 `status: "available"`, `retrievalStatus: "resolved"` 로 조회 성공을 표시하고, 실제 재고 여부는 `inStock` / `inventoryStatus` 로 표시
- `selStrPkupStck``401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 를 반환하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"`, `reason: "unauthorized"` 로 표시
- `POST /api/ms/msg/selPkupStr` → 픽업 재고가 차단되면 호출. 매장 픽업 가능 매장 목록을 받아 `pickupEligibility.pickupEligible`(true/false/null), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `totalCount` 로 응답 (수량 미제공). 검색 범위가 불충분하면 `retrievalStatus: "insufficient_coverage"``pickupEligible: null` 을 반환합니다.
- `POST /api/pdo/selOnlStck` → 가능한 경우 온라인 재고 참고값 표시

View file

@ -108,6 +108,7 @@
- 다이소몰 상품 검색 목록: 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 (2026-05-05 기준 Unauthorized 차단 가능)
- 다이소몰 매장 픽업 가능 매장 목록: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (특정 상품의 픽업 가능 매장 리스트, 매장 수량은 미제공)
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 마켓컬리 검색 API(v4): https://api.kurly.com/search/v4/sites/market/normal-search
- 마켓컬리 검색 개수 API(v3): https://api.kurly.com/search/v3/sites/market/normal-search/count

View file

@ -21,8 +21,9 @@ npm install
- 매장명과 상품명 둘 다 필요합니다.
- 공식 다이소몰 표면을 우선 사용합니다.
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공하지만, 다이소몰 보안 정책에 따라 `Unauthorized` 로 차단될 수 있습니다.
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인합니다.
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 공식 픽업 가능 매장 목록(`selPkupStr`) 으로 그 매장의 **픽업 가능 여부** 만이라도 `pickupEligibility` 로 회수합니다. 수량은 여전히 알 수 없습니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
- 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인할 수 있지만, 매장 재고로 단정해서는 안 됩니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
## 사용 예시
@ -92,6 +93,27 @@ main().catch((error) => {
}
```
2026-05-08 부터는 매장 픽업 재고가 차단되면 공식 픽업 가능 매장 목록 표면(`selPkupStr`)을 추가로 호출해 그 매장이 해당 상품의 픽업 가능 매장에 들어 있는지 여부만이라도 회수합니다. 수량은 여전히 알 수 없지만, "그 매장에서 이 상품을 픽업으로 살 수 있는지" 는 답할 수 있게 됩니다.
```json
{
"pickupEligibility": {
"pdNo": "1049275",
"strCd": "10224",
"pickupEligible": true,
"eligibleStoreCount": 1,
"matchedStore": {
"strCd": "10224",
"name": "강남역2호점",
"pickupAvailable": true,
"openTime": "10:00",
"closeTime": "22:00"
},
"retrievalStatus": "resolved"
}
}
```
## 공개 API
- `searchStores(query, options?)`
@ -102,6 +124,12 @@ main().catch((error) => {
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
- 다이소몰이 매장 픽업 재고를 `401`/`403` 또는 `{ "success": false, "message": "Unauthorized" }` 로 차단하면 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 결과를 반환합니다.
- `getStorePickupEligibility({ pdNo, strCd, storeName?, keyword?, pageSize? }, options?)`
- 공식 `POST /api/ms/msg/selPkupStr` 표면을 호출해 해당 상품의 픽업 가능 매장 목록을 받아 `pickupEligible` 여부를 판정합니다.
- `storeName` 이 주어지면 매장명에서 `N호점` 같은 접미사를 제거해 `keyword` 로 자동 변환합니다. `keyword` 를 직접 넘기면 그대로 사용합니다. `strCd` 조회에서 `storeName`/`keyword` 가 없거나 첫 페이지가 전체 결과를 다 덮지 못하면 확정 `false` 대신 `pickupEligible: null`, `retrievalStatus: "insufficient_coverage"` 를 반환합니다.
- 응답은 `pickupEligible`(`true`/`false`/`null`), `eligibleStoreCount`, `eligibleStores`, `matchedStore`, `searchedKeyword`, `pageSize`, `totalCount`, `retrievalStatus`, `raw` 를 포함합니다.
- 정확한 수량은 제공되지 않습니다. 수량 확인은 `selStrPkupStck` 를 통해야 하며 차단 시에는 확인 불가입니다.
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
- `lookupStoreProductAvailability({ storeQuery, productQuery, includePickupEligibility?, ...options })`
- `pickupStock.retrievalStatus === "blocked"` 일 때만 `selPkupStr` 폴백을 호출해 `pickupEligibility` 를 채웁니다. `includePickupEligibility: false` 로 끌 수 있습니다.

View file

@ -3,6 +3,7 @@ const {
BASE_SEARCH_URL,
buildSearchGoodsParams,
normalizeOnlineStockResponse,
normalizePickupEligibilityResponse,
normalizeProductIdentifier,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
@ -153,6 +154,73 @@ async function getOnlineStock(request, options = {}) {
return normalizeOnlineStockResponse(payload, normalizedRequest)
}
function buildPickupEligibilityKeyword(value) {
return String(value || "")
.replace(/\d+\s*호점\s*$/u, "")
.replace(/[(].*?[)]/gu, " ")
.replace(/\s+/gu, " ")
.trim()
}
async function getStorePickupEligibility(request, options = {}) {
const pdNo = String(request.pdNo || "").trim()
const strCd = String(request.strCd || "").trim()
const explicitKeyword =
typeof request.keyword === "string" && request.keyword.trim() ? request.keyword.trim() : null
const derivedKeyword = explicitKeyword || buildPickupEligibilityKeyword(request.storeName)
const pageSize = Number(request.pageSize || 50)
if (!pdNo) {
throw new Error("pdNo is required.")
}
if (strCd && !derivedKeyword) {
return {
pdNo,
strCd,
pickupEligible: null,
eligibleStoreCount: null,
eligibleStores: [],
matchedStore: null,
searchedKeyword: "",
pageSize,
totalCount: null,
retrievalStatus: "insufficient_coverage",
reason: "missing_search_keyword",
raw: null
}
}
try {
const payload = await requestJson(`${BASE_API_URL}/ms/msg/selPkupStr`, {
...options,
method: "POST",
body: {
pdNo,
keyword: derivedKeyword || "",
currentPage: 1,
pageSize
}
})
return normalizePickupEligibilityResponse(payload, {
pdNo,
strCd,
keyword: derivedKeyword || "",
pageSize
})
} catch (error) {
if (error instanceof DaisoRequestError) {
return normalizePickupEligibilityResponse(
error.payload || { success: false, message: `HTTP ${error.status}` },
{ pdNo, strCd, keyword: derivedKeyword || "", pageSize }
)
}
throw error
}
}
async function lookupStoreProductAvailability(options = {}) {
const storeQuery = String(options.storeQuery || "").trim()
const productQuery = String(options.productQuery || "").trim()
@ -180,9 +248,7 @@ async function lookupStoreProductAvailability(options = {}) {
const selectedStore = storeResult.items[0]
const selectedProduct = selectPickupPreferredProduct(productResult.items)
const [storeDetailPayload, pickupStock, onlineStock] = await Promise.all([
getStoreDetail(selectedStore.strCd, options),
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options),
const onlineStockPromise =
options.includeOnlineStock === false
? Promise.resolve(null)
: getOnlineStock(
@ -191,9 +257,30 @@ async function lookupStoreProductAvailability(options = {}) {
onldPdNo: selectedProduct.onldPdNo
},
options
)
).catch(() => null)
const [storeDetailPayload, pickupStock] = await Promise.all([
getStoreDetail(selectedStore.strCd, options),
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options)
])
let pickupEligibility = null
if (
options.includePickupEligibility !== false &&
pickupStock &&
pickupStock.retrievalStatus === "blocked"
) {
pickupEligibility = await getStorePickupEligibility(
{
pdNo: selectedProduct.pdNo,
strCd: selectedStore.strCd,
storeName: selectedStore.name
},
options
)
}
const onlineStock = await onlineStockPromise
return {
storeQuery,
productQuery,
@ -203,6 +290,7 @@ async function lookupStoreProductAvailability(options = {}) {
storeDetail: storeDetailPayload.data || null,
selectedProduct,
pickupStock,
pickupEligibility,
onlineStock
}
}
@ -210,6 +298,7 @@ async function lookupStoreProductAvailability(options = {}) {
module.exports = {
getOnlineStock,
getStoreDetail,
getStorePickupEligibility,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,

View file

@ -386,6 +386,120 @@ function normalizeOnlineStockResponse(payload, request) {
}
}
function normalizePickupEligibilityStore(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",
openTime: formatStoreTime(item.opngTime),
closeTime: formatStoreTime(item.clsngTime),
distanceKm: normalizeDistanceKm(item.km),
latitude: toNumberOrNull(item.strLttd),
longitude: toNumberOrNull(item.strLitd)
}
}
function firstNumericValue(...values) {
for (const value of values) {
if (value === null || value === undefined || value === "") {
continue
}
const numericValue = Number(value)
if (Number.isFinite(numericValue)) {
return numericValue
}
}
return null
}
function normalizePickupEligibilityResponse(payload, request) {
const requestStrCd = String(request.strCd || "")
const requestPdNo = String(request.pdNo || "")
const searchedKeyword = String(request.keyword || "").trim()
const requestedPageSize = firstNumericValue(request.pageSize)
if (!payload || typeof payload !== "object" || payload.success === false) {
return {
pdNo: requestPdNo,
strCd: requestStrCd,
pickupEligible: null,
eligibleStoreCount: null,
eligibleStores: [],
matchedStore: null,
searchedKeyword,
pageSize: requestedPageSize,
totalCount: null,
retrievalStatus: "blocked",
reason: "upstream_error",
raw: payload || null
}
}
const rawItems = Array.isArray(payload.data) ? payload.data : []
const eligibleStores = rawItems
.filter((item) => item && item.strCd)
.map(normalizePickupEligibilityStore)
const matchedStore = requestStrCd
? eligibleStores.find((store) => store.strCd === requestStrCd) || null
: null
const totalCount = firstNumericValue(
payload.totalCnt,
payload.totalCount,
payload.extraData && payload.extraData.totalCnt,
payload.extraData && payload.extraData.totalCount,
rawItems[0] && rawItems[0].totalCnt
)
const currentPageCount = firstNumericValue(
payload.currentPageCnt,
payload.currentPageCount,
payload.extraData && payload.extraData.currentPageCnt,
payload.extraData && payload.extraData.currentPageCount,
rawItems[0] && rawItems[0].currentPageCnt,
rawItems.length
)
const pageSize = requestedPageSize || currentPageCount
const hasMoreUnsearchedRows =
Boolean(requestStrCd) &&
!matchedStore &&
(totalCount === null ? Boolean(pageSize && rawItems.length >= pageSize) : totalCount > rawItems.length)
if (hasMoreUnsearchedRows) {
return {
pdNo: requestPdNo,
strCd: requestStrCd,
pickupEligible: null,
eligibleStoreCount: eligibleStores.length,
eligibleStores,
matchedStore,
searchedKeyword,
pageSize,
totalCount,
retrievalStatus: "insufficient_coverage",
reason: "search_page_not_exhausted",
raw: payload
}
}
return {
pdNo: requestPdNo,
strCd: requestStrCd,
pickupEligible: requestStrCd ? Boolean(matchedStore && matchedStore.pickupAvailable) : null,
eligibleStoreCount: eligibleStores.length,
eligibleStores,
matchedStore,
searchedKeyword,
pageSize,
totalCount,
retrievalStatus: "resolved",
raw: payload
}
}
module.exports = {
BASE_API_URL,
BASE_SEARCH_URL,
@ -393,6 +507,8 @@ module.exports = {
STORE_EMPTY_RESULT_ERROR,
buildSearchGoodsParams,
normalizeOnlineStockResponse,
normalizePickupEligibilityResponse,
normalizePickupEligibilityStore,
normalizeProductIdentifier,
normalizeProductItem,
normalizeSearchGoodsResponse,

View file

@ -0,0 +1,58 @@
{
"message": null,
"data": [
{
"rgpsId": null,
"regDttm": null,
"mdpsId": null,
"modDttm": null,
"totalCnt": 1,
"dataAllTotal": 0,
"currentPageCnt": 1,
"rnum": 1,
"status": null,
"frRgerId": null,
"frRgDtm": null,
"lsUpdrId": null,
"lsUpdtDtm": null,
"strCd": "10224",
"strNm": "강남역2호점",
"strZip": "06134",
"strAddr": "서울특별시 강남구 테헤란로 109 (역삼동)",
"strDtlAddr": null,
"strTno": "1522-4400",
"brchChgrCrryTno": null,
"parkYn": "N",
"nmtkYn": "N",
"phcYn": "N",
"usimYn": "Y",
"ovrseaUsimYn": "N",
"hbrdUsimYn": "N",
"phstkYn": "N",
"opngTime": "10:00",
"clsngTime": "22:00",
"pkupYn": "Y",
"bassPkupStrYn": "N",
"pntAcmYn": "Y",
"pntUseYn": "Y",
"elecRctwYn": "Y",
"strLitd": 127.028991200366,
"strLttd": 37.4988240219594,
"km": "13181.1",
"useYn": "Y",
"directYn": "Y",
"nocashYn": "N",
"hldyDt": null,
"sccardYn": "N",
"sccashYn": "N",
"elvtYn": "N",
"entrRampYn": "Y",
"taxfYn": "Y",
"hflpYn": "N"
}
],
"extraData": {},
"extraString": null,
"returnCode": null,
"success": true
}

View file

@ -6,6 +6,7 @@ const path = require("node:path")
const {
getOnlineStock,
getStoreDetail,
getStorePickupEligibility,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,
@ -13,6 +14,7 @@ const {
} = require("../src/index")
const {
buildSearchGoodsParams,
normalizePickupEligibilityResponse,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
normalizeStoreSearchResponse
@ -24,6 +26,9 @@ const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "se
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"))
const storePickupEligibilityPayload = JSON.parse(
fs.readFileSync(path.join(fixturesDir, "store-pickup-eligibility.json"), "utf8")
)
const liveSearchGoodsPayload = {
resultSet: {
result: [
@ -339,7 +344,7 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo")) {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
@ -355,6 +360,10 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
@ -374,6 +383,315 @@ test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pick
assert.equal(availability.pickupStock.reason, "unauthorized")
assert.equal(availability.onlineStock.quantity, 13047)
assert.equal(availability.onlineStock.referenceOnly, true)
assert.notEqual(availability.pickupEligibility, null)
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.pickupEligibility.retrievalStatus, "resolved")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability still resolves pickup eligibility when online stock fails", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
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({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.onlineStock, null)
} finally {
global.fetch = originalFetch
}
})
test("normalizePickupEligibilityResponse marks selected store as eligible when present in list", () => {
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
pdNo: "1049275",
strCd: "10224"
})
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.eligibleStoreCount, 1)
assert.equal(eligibility.matchedStore.strCd, "10224")
assert.equal(eligibility.matchedStore.name, "강남역2호점")
assert.equal(eligibility.matchedStore.pickupAvailable, true)
assert.equal(eligibility.matchedStore.openTime, "10:00")
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse marks selected store as NOT eligible when absent from list", () => {
const eligibility = normalizePickupEligibilityResponse(storePickupEligibilityPayload, {
pdNo: "1049275",
strCd: "99999"
})
assert.equal(eligibility.pickupEligible, false)
assert.equal(eligibility.eligibleStoreCount, 1)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse requires pickupAvailable for positive eligibility", () => {
const payload = {
...storePickupEligibilityPayload,
data: storePickupEligibilityPayload.data.map((item) => ({ ...item, pkupYn: "N" }))
}
const eligibility = normalizePickupEligibilityResponse(payload, {
pdNo: "1049275",
strCd: "10224",
keyword: "강남역",
pageSize: 50
})
assert.equal(eligibility.pickupEligible, false)
assert.equal(eligibility.matchedStore.strCd, "10224")
assert.equal(eligibility.matchedStore.pickupAvailable, false)
assert.equal(eligibility.retrievalStatus, "resolved")
})
test("normalizePickupEligibilityResponse avoids a definitive miss when the first page may be incomplete", () => {
const payload = {
...storePickupEligibilityPayload,
data: [
{
...storePickupEligibilityPayload.data[0],
totalCnt: 2,
currentPageCnt: 1,
strCd: "10000",
strNm: "서울테스트점"
}
]
}
const eligibility = normalizePickupEligibilityResponse(payload, {
pdNo: "1049275",
strCd: "10224",
keyword: "서울",
pageSize: 1
})
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.reason, "search_page_not_exhausted")
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
assert.equal(eligibility.searchedKeyword, "서울")
assert.equal(eligibility.totalCount, 2)
})
test("normalizePickupEligibilityResponse handles upstream failure as blocked retrieval", () => {
const eligibility = normalizePickupEligibilityResponse(
{ success: false, message: "Upstream error" },
{ pdNo: "1049275", strCd: "10224" }
)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.eligibleStoreCount, null)
assert.equal(eligibility.eligibleStores.length, 0)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
})
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
const originalFetch = global.fetch
let capturedBody = null
let capturedUrl = null
global.fetch = async (url, init = {}) => {
capturedUrl = String(url)
capturedBody = JSON.parse(init.body)
return makeResponse(storePickupEligibilityPayload)
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
assert.equal(capturedBody.pdNo, "1049275")
assert.equal(capturedBody.keyword, "강남역")
assert.equal(capturedBody.currentPage, 1)
assert.equal(typeof capturedBody.pageSize, "number")
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.matchedStore.strCd, "10224")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility does not emit a definitive false without a store keyword", async () => {
const originalFetch = global.fetch
let fetchCalled = false
global.fetch = async () => {
fetchCalled = true
return makeResponse({ ...storePickupEligibilityPayload, data: [] })
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224"
})
assert.equal(fetchCalled, false)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.retrievalStatus, "insufficient_coverage")
assert.equal(eligibility.reason, "missing_search_keyword")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility surfaces upstream HTTP errors as blocked retrieval", async () => {
const originalFetch = global.fetch
global.fetch = async () =>
makeResponse({ success: false, message: "Internal Server Error" }, { status: 500 })
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability skips pickup eligibility lookup when pickup stock resolves", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
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/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "resolved")
assert.equal(eligibilityCalled, false)
assert.equal(availability.pickupEligibility, null)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability respects includePickupEligibility=false even when pickup stock is blocked", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
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({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100",
includePickupEligibility: false
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(eligibilityCalled, false)
assert.equal(availability.pickupEligibility, null)
} finally {
global.fetch = originalFetch
}