Feature/#207 (#209)

This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-06 12:38:52 +09:00 committed by GitHub
commit e87330874b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 247 additions and 30 deletions

View file

@ -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.

View file

@ -17,6 +17,7 @@ metadata:
- 공식 매장 검색으로 매장 코드를 찾는다.
- 공식 상품 검색으로 상품 후보를 찾는다.
- 공식 매장 픽업 재고 표면으로 해당 매장의 재고를 확인한다.
- 다이소몰이 매장 픽업 재고 표면을 `Unauthorized` 로 차단하면 차단 상태를 그대로 보고하고 세션 우회는 시도하지 않는다.
- **공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로만 답한다.**
## When to use
@ -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")
@ -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
@ -140,15 +143,15 @@ console.log(result.pickupStock)
- 매장명
- 상품명
- 매장 재고 수량 또는 재고 없음
- 필요하면 온라인 재고 참고값
- 매장 재고 수량, 재고 없음, 또는 `retrievalStatus: "blocked"` / `Unauthorized` 로 인한 확인 불가
- 필요하면 `referenceOnly: true` 로 표시된 온라인 재고 참고값
- **공식 표면이 매장 내 진열 위치를 주지 않으면 `공식 표면에서는 매장 재고까지만 확인된다`고 분명히 말한다.**
## Done when
- 매장명과 상품명이 모두 확인되었다.
- 공식 표면으로 매장 후보와 상품 후보를 찾았다.
- 공식 매장 재고 결과를 최소 1회 반환했다.
- 공식 매장 재고 결과 또는 `Unauthorized` 차단 상태를 최소 1회 반환했다.
- 위치 정보가 없으면 없다고 분명히 고지했다.
## Failure modes
@ -156,6 +159,7 @@ console.log(result.pickupStock)
- 매장명이 너무 넓으면 같은 상권의 여러 지점이 동시에 잡힐 수 있다.
- 상품명이 너무 넓으면 다른 용량/호수 후보가 많이 섞일 수 있다.
- 공식 재고는 시점 차이로 실제 방문 시 수량이 달라질 수 있다.
- `selStrPkupStck``Unauthorized` 로 차단되면 매장 픽업 수량은 확인 불가로 답하고, 온라인 재고를 매장 재고처럼 단정하지 않는다.
- 현재 확인된 공식 표면은 **매장 내 aisle/진열 위치**를 직접 주지 않을 수 있다.
## Notes

View file

@ -5,7 +5,7 @@
- 다이소 매장명으로 공식 매장 후보 찾기
- 상품명/검색어로 공식 상품 후보 찾기
- 특정 매장의 **매장 픽업 재고** 확인
- 필요하면 온라인 재고 참고값 함께 확인
- 매장 픽업 재고가 `Unauthorized` 로 차단되면 `retrievalStatus: "blocked"` 차단 상태를 명확히 표시하고, 필요하면 `referenceOnly: true` 온라인 재고 참고값 함께 확인
## 먼저 필요한 것
@ -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. 공식 표면이 매장 내 위치를 주지 않으면 재고 중심으로 답합니다.
## 예시
@ -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` → 가능한 경우 온라인 재고 참고값 표시

View file

@ -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

View file

@ -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"`) 를 기준으로 판단합니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
## 사용 예시
@ -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."
}
}
```
@ -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 })`

View file

@ -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",
@ -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 = {}) {
@ -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)
return normalizeStorePickupStockResponse(payload, request)
} catch (error) {
if (isPickupStockUnauthorizedError(error)) {
return normalizeStorePickupStockResponse(
error.payload || { success: false, message: "Unauthorized", status: error.status },
request
)
}
throw error
}
}
async function getOnlineStock(request, options = {}) {

View file

@ -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.")
}
@ -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
}
@ -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
}
}

View file

@ -202,6 +202,56 @@ test("normalizeStorePickupStockResponse maps stock rows into a public availabili
assert.equal(stock.quantity, 3)
assert.equal(stock.inStock, true)
assert.equal(stock.saleStatusCode, "1")
assert.equal(stock.status, "available")
assert.equal(stock.retrievalStatus, "resolved")
assert.equal(stock.inventoryStatus, "in_stock")
})
test("normalizeStorePickupStockResponse separates retrieval status from zero-stock inventory status", () => {
const stock = normalizeStorePickupStockResponse(
{
...storePickupStockPayload,
data: [
{
...storePickupStockPayload.data[0],
stck: "0"
}
]
},
{
pdNo: "1049275",
strCd: "10224"
}
)
assert.equal(stock.quantity, 0)
assert.equal(stock.inStock, false)
assert.equal(stock.status, "available")
assert.equal(stock.retrievalStatus, "resolved")
assert.equal(stock.inventoryStatus, "out_of_stock")
})
test("normalizeStorePickupStockResponse marks Daiso Unauthorized payloads as unavailable", () => {
const stock = normalizeStorePickupStockResponse(
{ success: false, message: "Unauthorized" },
{
pdNo: "1049275",
strCd: "10224"
}
)
assert.deepEqual(stock, {
pdNo: "1049275",
strCd: "10224",
quantity: null,
inStock: null,
status: "unavailable",
retrievalStatus: "blocked",
inventoryStatus: "unknown",
reason: "unauthorized",
message: "Daiso Mall blocked store pickup stock lookup with Unauthorized.",
raw: { success: false, message: "Unauthorized" }
})
})
test("public client helpers can consume injected fetch fixtures", async () => {
@ -243,9 +293,11 @@ test("public client helpers can consume injected fetch fixtures", async () => {
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(pickupStock.quantity, 3)
assert.equal(pickupStock.inventoryStatus, "in_stock")
const onlineStock = await getOnlineStock({ pdNo: "1049275" })
assert.equal(onlineStock.quantity, 13047)
assert.equal(onlineStock.referenceOnly, true)
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
@ -254,12 +306,79 @@ test("public client helpers can consume injected fetch fixtures", async () => {
assert.equal(availability.selectedStore.strCd, "10224")
assert.equal(availability.selectedProduct.pdNo, "1049275")
assert.equal(availability.pickupStock.quantity, 3)
assert.equal(availability.pickupStock.inventoryStatus, "in_stock")
assert.equal(availability.onlineStock.quantity, 13047)
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupStock converts Daiso pickup-stock 401 responses to unavailable results", async () => {
const originalFetch = global.fetch
global.fetch = async (url) => {
assert.match(String(url), /\/api\/pd\/pdh\/selStrPkupStck$/)
return makeResponse({ success: false, message: "Unauthorized" }, { status: 401 })
}
try {
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(pickupStock.status, "unavailable")
assert.equal(pickupStock.retrievalStatus, "blocked")
assert.equal(pickupStock.inventoryStatus, "unknown")
assert.equal(pickupStock.reason, "unauthorized")
assert.equal(pickupStock.quantity, null)
assert.equal(pickupStock.inStock, null)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability keeps online-stock fallback when Daiso pickup stock is unauthorized", 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({ success: false, message: "Unauthorized" }, { status: 403 })
}
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.status, "unavailable")
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupStock.inventoryStatus, "unknown")
assert.equal(availability.pickupStock.reason, "unauthorized")
assert.equal(availability.onlineStock.quantity, 13047)
assert.equal(availability.onlineStock.referenceOnly, true)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability falls back to pdNo when live SearchGoods returns placeholder online stock ids", async () => {
const originalFetch = global.fetch
@ -444,9 +563,9 @@ test("lookupStoreProductAvailability reuses a product candidate's online stock i
}
})
function makeResponse(body) {
function makeResponse(body, options = {}) {
return new Response(JSON.stringify(body), {
status: 200,
status: options.status || 200,
headers: {
"content-type": "application/json"
}