Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/issue-207-daiso-unauthorized.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"daiso-product-search": patch
---

Handle Daiso Mall pickup-stock Unauthorized responses as structured unavailable results, include pickup-stock retrieval and inventory states, and mark online-stock fallback as reference-only.
12 changes: 8 additions & 4 deletions daiso-product-search/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ metadata:
- 공식 매장 검색으로 매장 코드를 찾는다.
- 공식 상품 검색으로 상품 후보를 찾는다.
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
- 다이소몰이 매장 픽업 재고 표면을 `Unauthorized` 로 차단하면 차단 상태를 그대로 보고하고 세션 우회는 시도하지 않는다.
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**

## When to use
Expand Down Expand Up @@ -106,7 +107,7 @@ console.log(productResult.items)

### 3. Check the store pickup stock

공식 매장 픽업 재고 API로 해당 매장의 재고를 확인한다.
공식 매장 픽업 재고 API로 해당 매장의 재고를 확인한다. 2026-05-05 기준 이 엔드포인트가 `Unauthorized` 로 차단될 수 있으므로, `stock.retrievalStatus === "blocked"` 또는 `stock.status === "unavailable"` 이면 정확한 매장 수량을 단정하지 않는다. `stock.status` 는 조회 결과 범주이고, 실제 재고 여부는 `stock.inStock` 또는 `stock.inventoryStatus` 로 판단한다.

```js
const { getStorePickupStock } = require("daiso-product-search")
Expand All @@ -117,6 +118,8 @@ const stock = await getStorePickupStock({
})

console.log(stock)
// 품절 예시: { status: "available", retrievalStatus: "resolved", inventoryStatus: "out_of_stock", quantity: 0, inStock: false }
// 차단 예시: { 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
Expand All @@ -140,22 +143,23 @@ console.log(result.pickupStock)

- 매장명
- 상품명
- 매장 재고 수량 또는 재고 없음
- 필요하면 온라인 재고 참고값
- 매장 재고 수량, 재고 없음, 또는 `retrievalStatus: "blocked"` / `Unauthorized` 로 인한 확인 불가
- 필요하면 `referenceOnly: true` 로 표시된 온라인 재고 참고값
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**

## Done when

- 매장명과 상품명이 모두 확인되었다.
- 공식 표면으로 매장 후보와 상품 후보를 찾았다.
- 공식 매장 재고 결과를 최소 1회 반환했다.
- 공식 매장 재고 결과 또는 `Unauthorized` 차단 상태를 최소 1회 반환했다.
- 위치 정보가 없으면 없다고 분명히 고지했다.

## Failure modes

- 매장명이 너무 넓으면 같은 상권의 여러 지점이 동시에 잡힐 수 있다.
- 상품명이 너무 넓으면 다른 용량/호수 후보가 많이 섞일 수 있다.
- 공식 재고는 시점 차이로 실제 방문 시 수량이 달라질 수 있다.
- `selStrPkupStck` 가 `Unauthorized` 로 차단되면 매장 픽업 수량은 확인 불가로 답하고, 온라인 재고를 매장 재고처럼 단정하지 않는다.
- 현재 확인된 공식 표면은 **매장 내 aisle/진열 위치**를 직접 주지 않을 수 있다.

## Notes
Expand Down
21 changes: 13 additions & 8 deletions docs/features/daiso-product-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고** 확인
- 필요하면 온라인 재고 참고값 함께 확인
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인

## 먼저 필요한 것

Expand Down Expand Up @@ -37,8 +37,9 @@
3. `selStr` 로 매장 후보를 찾고, 필요하면 `selStrInfo` 로 매장 상세를 확인합니다.
4. `SearchGoods` 로 상품 후보를 찾습니다.
5. `selStrPkupStck` 로 해당 매장의 상품 재고를 확인합니다.
6. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
7. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
6. `selStrPkupStck` 가 `Unauthorized` 로 차단되면 매장 픽업 재고는 `unavailable/blocked/unauthorized` 로 보고하고 세션 우회를 시도하지 않습니다.
7. 필요하면 `SearchGoods` 응답의 `onldPdNo` 를 함께 보존해 `selOnlStck` 온라인 재고 교차 확인에 사용합니다.
8. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.

## 예시

Expand Down Expand Up @@ -71,13 +72,17 @@ main().catch((error) => {
- 상품 후보가 여러 개면 브랜드, 용량, 호수까지 같이 보여 주는 편이 덜 헷갈립니다.
- 재고 수량은 실시간 100% 보장값이 아니므로, 필요하면 `방문 직전 다시 확인` 문구를 같이 줍니다.
- 공식 표면이 매장 내 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 답합니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 상품 재고 여부는 `inStock` 또는 `inventoryStatus` 로 설명하고, `status: "available"` 만으로 재고가 있다고 말하지 않습니다.
- 매장 픽업 재고가 `Unauthorized` 로 차단된 경우에는 `다이소몰이 현재 매장 픽업 재고 API를 차단해 정확한 매장 재고 수량은 확인할 수 없다`고 답하고, 결과의 `retrievalStatus: "blocked"` 와 온라인 재고의 `referenceOnly: true` 참고값을 구분합니다.

## 라이브 확인 메모

2026-03-27 기준으로 다음 공식 호출이 실제 응답을 반환했습니다.
2026-03-27 기준으로 `selStrPkupStck` 는 실제 매장 픽업 재고를 반환했지만, 2026-05-05 기준 이 엔드포인트가 `Unauthorized` 로 차단되는 사례가 확인되었습니다.

- `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` 으로 응답했습니다. 즉, **공식 경로가 실제로 동작함은 확인했지만 당시 해당 매장 재고는 없었습니다.**
- `POST /api/ms/msg/selStr` → 매장 후보 확인
- `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/pdo/selOnlStck` → 가능한 경우 온라인 재고 참고값 표시
2 changes: 1 addition & 1 deletion docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@
- 다이소몰 상품 검색 요약: 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/pd/pdh/selStrPkupStck (2026-05-05 기준 Unauthorized 차단 가능)
- 다이소몰 온라인 재고: 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
Expand Down
31 changes: 29 additions & 2 deletions packages/daiso-product-search/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ npm install

- 매장명과 상품명 둘 다 필요합니다.
- 공식 다이소몰 표면을 우선 사용합니다.
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공합니다.
- 현재 확인된 공식 표면은 **매장 픽업 재고**를 제공하지만, 다이소몰 보안 정책에 따라 `Unauthorized` 로 차단될 수 있습니다.
- 매장 픽업 재고가 차단되면 `pickupStock.status === "unavailable"`, `retrievalStatus === "blocked"`, `reason === "unauthorized"` 로 반환하고, 가능한 경우 `onlineStock.referenceOnly === true` 인 온라인 재고 참고값을 함께 확인합니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.

## 사용 예시
Expand Down Expand Up @@ -64,7 +66,28 @@ main().catch((error) => {
"strCd": "10224",
"pdNo": "1049275",
"quantity": 0,
"inStock": false
"inStock": false,
"status": "available",
"retrievalStatus": "resolved",
"inventoryStatus": "out_of_stock"
}
}
```

2026-05-05 현재 `selStrPkupStck` 가 `Unauthorized` 로 차단되는 경우가 확인되어, 이 패키지는 해당 응답을 예외로 전파하지 않고 아래 형태로 정규화합니다. 이 동작은 세션 우회 없이 공식 표면의 제한을 보수적으로 보고하기 위한 것입니다.

```json
{
"pickupStock": {
"strCd": "10224",
"pdNo": "1049275",
"quantity": null,
"inStock": null,
"status": "unavailable",
"retrievalStatus": "blocked",
"inventoryStatus": "unknown",
"reason": "unauthorized",
"message": "Daiso Mall blocked store pickup stock lookup with Unauthorized."
}
}
```
Expand All @@ -76,5 +99,9 @@ main().catch((error) => {
- `searchProducts(query, options?)`
- 반환되는 각 상품 후보는 `pdNo` 와 함께 `onldPdNo` 를 포함할 수 있습니다. 다이소몰 온라인 재고 표면이 별도 마스터 상품 번호를 요구하는 경우 이 값을 그대로 `getOnlineStock()` 에 넘기면 됩니다.
- `getStorePickupStock({ pdNo, strCd }, options?)`
- 성공한 조회는 `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"` 결과를 반환합니다.
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
60 changes: 47 additions & 13 deletions packages/daiso-product-search/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ const {
normalizeStoreSearchResponse
} = require("./parse")

class DaisoRequestError extends Error {
constructor(message, options = {}) {
super(message)
this.name = "DaisoRequestError"
this.status = options.status || null
this.payload = options.payload || null
this.url = options.url || null
}
}

const DEFAULT_BROWSER_HEADERS = {
accept: "application/json, text/plain, */*",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
Expand Down Expand Up @@ -43,12 +53,25 @@ async function requestJson(url, options = {}) {
}

const response = await fetchImpl(url, init)
const payload = await response.json().catch(() => null)

if (!response.ok) {
throw new Error(`Daiso request failed with ${response.status} for ${url}`)
throw new DaisoRequestError(`Daiso request failed with ${response.status} for ${url}`, {
status: response.status,
payload,
url
})
}

return response.json()
return payload
}

function isPickupStockUnauthorizedError(error) {
return (
error instanceof DaisoRequestError &&
(error.status === 401 || error.status === 403) &&
(!error.payload || /unauthorized/i.test(String(error.payload.message || "")))
)
}

async function searchStores(query, options = {}) {
Expand Down Expand Up @@ -91,18 +114,29 @@ async function searchProducts(query, options = {}) {
}

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)
}
]
})
try {
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)
} catch (error) {
if (isPickupStockUnauthorizedError(error)) {
return normalizeStorePickupStockResponse(
error.payload || { success: false, message: "Unauthorized", status: error.status },
request
)
}

return normalizeStorePickupStockResponse(payload, request)
throw error
}
}

async function getOnlineStock(request, options = {}) {
Expand Down
23 changes: 23 additions & 0 deletions packages/daiso-product-search/src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,26 @@ function normalizeSearchGoodsResponse(payload, query) {
}
}

function isUnauthorizedMessage(value) {
return /unauthorized/i.test(String(value || ""))
}

function normalizeStorePickupStockResponse(payload, request) {
if (payload && typeof payload === "object" && payload.success === false && isUnauthorizedMessage(payload.message)) {
return {
pdNo: String(request.pdNo),
strCd: String(request.strCd),
quantity: null,
inStock: null,
status: "unavailable",
retrievalStatus: "blocked",
inventoryStatus: "unknown",
reason: "unauthorized",
message: "Daiso Mall blocked store pickup stock lookup with Unauthorized.",
raw: payload
}
}

if (!payload || typeof payload !== "object" || !Array.isArray(payload.data) || payload.data.length === 0) {
throw new Error("No Daiso pickup stock rows were returned.")
}
Expand All @@ -340,6 +359,9 @@ function normalizeStorePickupStockResponse(payload, request) {
strCd: String(item.strCd || request.strCd),
quantity,
inStock: quantity > 0,
status: "available",
retrievalStatus: "resolved",
inventoryStatus: quantity > 0 ? "in_stock" : "out_of_stock",
saleStatusCode: item.sleStsCd || null,
raw: item
}
Expand All @@ -359,6 +381,7 @@ function normalizeOnlineStockResponse(payload, request) {
firstPresentProductIdentifier(item.onldPdNo, request.onldPdNo, request.pdNo) || String(request.pdNo),
quantity,
inStock: quantity > 0,
referenceOnly: true,
raw: item
}
}
Expand Down
Loading
Loading