Add a supported Market Kurly price-lookup skill

Implement a reusable Market Kurly workspace package plus a repo skill/doc set
that uses the unauthenticated Kurly search and goods-page surfaces.
The change keeps the scope read-only, adds regression coverage, updates
release/docs metadata, and records the new publishable package through
Changesets.

Constraint: Must rely on unauthenticated public web surfaces instead of login/session flows
Constraint: Release workflow requires Changesets for publishable Node packages
Rejected: Docs-only skill | issue approval called for real lookup helpers and live verification
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Kurly endpoints are web-internal surfaces; verify schema behavior before extending fields or adding action flows
Tested: npm run ci; live node smoke for countProducts/searchProducts/getProductDetail on 2026-04-09
Not-tested: Pagination beyond page 1; long-term stability of Kurly internal response schema
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-09 14:51:30 +09:00
commit 4ce29ae009
15 changed files with 875 additions and 2 deletions

View file

@ -0,0 +1,5 @@
---
"market-kurly-search": minor
---
Publish the first reusable Market Kurly product search package and skill docs for unauthenticated price lookups.

View file

@ -47,6 +47,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
@ -112,6 +113,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)

View file

@ -0,0 +1,72 @@
# 마켓컬리 상품 조회 가이드
## 이 기능으로 할 수 있는 일
- 마켓컬리 상품 키워드 검색
- 현재 가격 확인
- 필요하면 원가/할인가 여부 확인
- 품절 여부와 배송 타입 확인
- 상품 링크 반환
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
## 입력값
- 상품명 또는 검색어
- 예: `우유`
- 예: `딸기`
- 예: `닭가슴살`
## 공식 표면
- search list: `https://api.kurly.com/search/v4/sites/market/normal-search?keyword=<keyword>&page=1`
- search count: `https://api.kurly.com/search/v3/sites/market/normal-search/count?keyword=<keyword>&filters=&allow_replace=true`
- goods detail page: `https://www.kurly.com/goods/<productNo>`
## 기본 흐름
1. 상품명/검색어가 없으면 먼저 물어봅니다.
2. `normal-search` 로 상품 후보를 찾습니다.
3. 후보가 너무 많으면 `count` endpoint 로 검색 결과 규모를 먼저 보여 줍니다.
4. 결과에서 상품명, 현재 가격, 할인율, 품절 여부, 배송 타입, 링크를 짧고 **보수적으로** 정리합니다.
5. 필요하면 `goods/<productNo>` 페이지의 `__NEXT_DATA__` 를 읽어 상세 정보를 보조 확인합니다.
6. 가격/품절/노출 정보는 시점에 따라 달라질 수 있으므로 조회 시각 기준 참고값이라고 답합니다.
## 예시
```js
const { countProducts, getProductDetail, searchProducts } = require("market-kurly-search")
async function main() {
const count = await countProducts("우유")
const search = await searchProducts("우유")
const detail = await getProductDetail(search.items[0].productNo)
console.log({ count, firstItem: search.items[0], detail })
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 실전 운영 팁
- 검색어가 너무 넓으면 브랜드, 용량, 맛, 카테고리를 다시 물어보는 편이 안전합니다.
- 할인 상품은 `discountedPrice` 가 현재 가격이고 `salesPrice` 가 기준 가격일 수 있습니다.
- 품절 여부가 `false` 여도 실제 결제 시점에는 달라질 수 있으니 주문 가능을 확정처럼 말하면 안 됩니다.
- 비로그인 조회로는 장바구니/주문/주소 기반 배송 가능 여부를 확정할 수 없습니다.
## 라이브 확인 메모
2026-04-09 기준 아래 공개 호출이 로그인 없이 응답했습니다.
- `GET /search/v4/sites/market/normal-search?keyword=우유&page=1``no`, `name`, `salesPrice`, `discountedPrice`, `discountRate`, `isSoldOut`, `deliveryTypeNames` 확인
- `GET /search/v3/sites/market/normal-search/count?keyword=우유&filters=&allow_replace=true``count = 468` 확인
- `GET /goods/5063110``__NEXT_DATA__` 에서 상품명, 가격, 품절 여부, 배송 타입 확인
즉, **2026-04-09 기준으로는 마켓컬리 상품 검색과 가격 조회를 로그인 없이 구현할 수 있음** 을 다시 검증했습니다. 다만 이 표면은 웹 내부 사용 경로이므로 이후 스키마/헤더 요구사항이 바뀌면 수정이 필요할 수 있습니다.

View file

@ -65,6 +65,7 @@ npx --yes skills add <owner/repo> \
--skill fine-dust-location \
--skill han-river-water-level \
--skill daiso-product-search \
--skill market-kurly-search \
--skill olive-young-search \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
@ -249,7 +250,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp daiso bunjang-cli
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
export NODE_PATH="$(npm root -g)"
```

View file

@ -29,6 +29,7 @@
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 번개장터 검색 스킬 출시

View file

@ -72,6 +72,9 @@
- 다이소몰 상품 요약 목록: 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
- 마켓컬리 검색 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
- 마켓컬리 상품 상세 페이지 예시: https://www.kurly.com/goods/5063110
- olive-young / multi-retail upstream repo (`hmmhmmhm/daiso-mcp`): https://github.com/hmmhmmhm/daiso-mcp
- olive-young CLI package (`daiso`): https://www.npmjs.com/package/daiso
- olive-young stores API: https://mcp.aka.page/api/oliveyoung/stores

View file

@ -0,0 +1,135 @@
---
name: market-kurly-search
description: 로그인 없이 접근 가능한 마켓컬리 검색/상품 상세 표면으로 상품 후보, 현재 가격, 할인 여부, 품절 여부를 조회한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Market Kurly Search
## What this skill does
마켓컬리 웹앱이 실제로 사용하는 **비로그인 검색/상품 상세 표면**을 사용해 아래 흐름을 처리한다.
- 키워드로 상품 후보를 검색한다.
- 현재 가격과 할인 여부를 확인한다.
- 품절 여부와 배송 타입을 확인한다.
- 상품 링크를 함께 반환한다.
- **주문/장바구니 같은 액션은 하지 않는다. 조회형으로만 답한다.**
## When to use
- "마켓컬리에서 우유 얼마야?"
- "컬리에서 딸기 검색해줘"
- "이 상품 품절인지 보고 링크도 줘"
- "지금 컬리 가격만 빠르게 보고 싶어"
## When not to use
- 주문/장바구니/결제까지 자동화해야 하는 경우
- 주소 기반 배송 가능 여부나 회원 전용 가격을 확정해야 하는 경우
- 로그인 세션이 필요한 개인화 추천/찜 정보를 조회해야 하는 경우
## Prerequisites
- 인터넷 연결
- `node` 18+
- 이 저장소의 `market-kurly-search` package 또는 동일 로직
## Required inputs
### 1. Ask for a product keyword if it is missing
상품명 또는 검색어가 없으면 먼저 물어본다.
- 권장 질문: `찾을 마켓컬리 상품명이나 검색어를 알려주세요. 예: 우유, 딸기, 닭가슴살`
- 너무 넓으면: `검색어가 너무 넓어요. 브랜드나 용량까지 같이 알려주시면 가격 후보를 더 정확히 추릴 수 있어요.`
### 2. Confirm which candidate they want when the query is ambiguous
검색 결과가 여러 개면 상위 2~3개만 보여주고 다시 확인받는다.
- 권장 질문: `후보가 여러 개예요. 아래 상품 중 어떤 상품 가격을 볼까요?`
- 응답에는 상품명 + 현재 가격 + 품절 여부 + 링크를 같이 붙인다.
## Official Market Kurly surfaces
- search list: `https://api.kurly.com/search/v4/sites/market/normal-search?keyword=<keyword>&page=1`
- search count: `https://api.kurly.com/search/v3/sites/market/normal-search/count?keyword=<keyword>&filters=&allow_replace=true`
- product detail page: `https://www.kurly.com/goods/<productNo>`
## Workflow
### 1. Search by keyword first
```js
const { searchProducts } = require("market-kurly-search")
const result = await searchProducts("우유")
console.log(result.items.slice(0, 3))
```
검색 결과에서는 아래 필드를 우선 본다.
- 상품명
- 현재 가격 (`discountedPrice` 우선, 없으면 `salesPrice`)
- 할인율
- 품절 여부
- 배송 타입
- 상품 링크
### 2. Use the count endpoint when the result set is broad
```js
const { countProducts } = require("market-kurly-search")
const count = await countProducts("우유")
console.log(count)
```
후보가 너무 많으면 `count` 를 먼저 보여 주고 검색어를 좁히라고 안내한다.
### 3. Use the goods page detail as a fallback or follow-up lookup
```js
const { getProductDetail } = require("market-kurly-search")
const detail = await getProductDetail(5063110)
console.log(detail)
```
`goods/<productNo>` HTML 안의 `__NEXT_DATA__` 에서 상품명, 가격, 품절 여부, 배송 타입을 추출한다.
### 4. Respond conservatively
응답은 짧고 보수적으로 정리한다.
- 상품명
- 현재 가격
- 필요하면 원가/할인가 여부
- 품절 여부 또는 판매 가능 여부
- 상품 링크
- **가격/품절/노출 정보는 시점에 따라 달라질 수 있으니 조회 시각 기준 참고값이라고 분명히 말한다.**
## Done when
- 상품 키워드를 확인했다.
- 검색 결과에서 후보와 현재 가격을 최소 1개 이상 반환했다.
- 필요하면 상품 상세 페이지로 보조 확인했다.
- 주문/장바구니 같은 범위 밖 액션은 하지 않았다.
## Failure modes
- 검색어가 너무 넓으면 후보가 과도하게 많아질 수 있다.
- 가격/품절/배송 문구는 시점에 따라 달라질 수 있다.
- 현재 확인한 표면은 **공식 개발자 Open API가 아니라 웹이 쓰는 공개 표면** 이므로 스키마가 바뀌면 깨질 수 있다.
- 회원 전용/주소 전용 정보는 비로그인 조회만으로 확정할 수 없다.
## Notes
- 조회형 스킬이다.
- 비로그인 공개 표면 우선 원칙을 유지한다.
- 주문/장바구니/로그인 요구 기능은 시도하지 않는다.

11
package-lock.json generated
View file

@ -1050,6 +1050,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/market-kurly-search": {
"resolved": "packages/market-kurly-search",
"link": true
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
@ -1707,6 +1711,13 @@
"node": ">=18"
}
},
"packages/market-kurly-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.2.0",
"license": "MIT",

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,79 @@
# market-kurly-search
마켓컬리 웹이 실제로 사용하는 **비로그인 검색/상품 상세 표면**을 사용해 상품 후보와 현재 가격을 조회하는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install market-kurly-search
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 로그인 없이 확인 가능한 공개 웹 표면만 사용합니다.
- 현재 가격은 `discountedPrice` 가 있으면 그 값을, 없으면 `salesPrice` 를 사용합니다.
- 가격/품절/배송 문구는 시점에 따라 달라질 수 있으므로 조회 시각 기준 참고값으로만 답해야 합니다.
- 장바구니/주문/주소 기반 배송 가능 여부 같은 회원/액션 기능은 범위 밖입니다.
## 사용 예시
```js
const { countProducts, getProductDetail, searchProducts } = require("market-kurly-search")
async function main() {
const searchResult = await searchProducts("우유")
const detailResult = await getProductDetail(searchResult.items[0].productNo)
const countResult = await countProducts("우유")
console.log(countResult)
console.log(searchResult.items[0])
console.log(detailResult)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 공개 API
- `searchProducts(keyword, options?)`
- `countProducts(keyword, options?)`
- `getProductDetail(productNo, options?)`
## Live smoke snapshot
2026-04-09 기준 live smoke test 에서 아래 공개 표면이 로그인 없이 응답했습니다.
```json
{
"count": {
"query": "우유",
"count": 468
},
"firstSearchItem": {
"productNo": 5063110,
"name": "[연세우유 x 마켓컬리] 전용목장우유 900mL",
"currentPrice": 2780,
"isSoldOut": false,
"goodsUrl": "https://www.kurly.com/goods/5063110"
},
"detail": {
"productNo": 5063110,
"name": "[연세우유 x 마켓컬리] 전용목장우유 900mL",
"currentPrice": 2780,
"deliveryTypeNames": [
"샛별배송(내일 아침)"
]
}
}
```

View file

@ -0,0 +1,32 @@
{
"name": "market-kurly-search",
"version": "0.1.0",
"description": "Unauthenticated Market Kurly product search and detail client",
"license": "MIT",
"main": "src/index.js",
"files": [
"src",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"kurly",
"market-kurly",
"retail",
"price"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,93 @@
const {
KURLY_WEB_BASE_URL,
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeKeyword,
normalizeProductNo,
normalizeSearchResponse
} = require("./parse")
const KURLY_API_BASE_URL = "https://api.kurly.com"
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 request(url, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.")
}
const response = await fetchImpl(url, {
method: options.method || "GET",
headers: {
...DEFAULT_BROWSER_HEADERS,
...(options.headers || {})
},
signal: options.signal
})
if (!response.ok) {
throw new Error(`Market Kurly request failed with ${response.status} for ${url}`)
}
return response
}
async function requestJson(url, options = {}) {
const response = await request(url, options)
return response.json()
}
async function requestText(url, options = {}) {
const response = await request(url, {
...options,
headers: {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
...(options.headers || {})
}
})
return response.text()
}
async function searchProducts(keyword, options = {}) {
const normalizedKeyword = normalizeKeyword(keyword)
const url = new URL(`${KURLY_API_BASE_URL}/search/v4/sites/market/normal-search`)
url.searchParams.set("keyword", normalizedKeyword)
url.searchParams.set("page", String(options.page || 1))
const payload = await requestJson(url.toString(), options)
return normalizeSearchResponse(payload, normalizedKeyword)
}
async function countProducts(keyword, options = {}) {
const normalizedKeyword = normalizeKeyword(keyword)
const url = new URL(`${KURLY_API_BASE_URL}/search/v3/sites/market/normal-search/count`)
url.searchParams.set("keyword", normalizedKeyword)
url.searchParams.set("filters", options.filters || "")
url.searchParams.set("allow_replace", options.allowReplace === false ? "false" : "true")
const payload = await requestJson(url.toString(), options)
return normalizeCountResponse(payload, normalizedKeyword)
}
async function getProductDetail(productNo, options = {}) {
const normalizedProductNo = normalizeProductNo(productNo)
const html = await requestText(`${KURLY_WEB_BASE_URL}/goods/${normalizedProductNo}`, options)
const nextData = extractNextDataJson(html)
return findProductDetail(nextData)
}
module.exports = {
countProducts,
getProductDetail,
searchProducts
}

View file

@ -0,0 +1,204 @@
const KURLY_WEB_BASE_URL = "https://www.kurly.com"
const SEARCH_EMPTY_RESULT_ERROR = "No Market Kurly product candidates were returned."
const COUNT_EMPTY_RESULT_ERROR = "No Market Kurly result count was returned."
const DETAIL_EMPTY_RESULT_ERROR = "No Market Kurly product detail was returned."
const NEXT_DATA_MISSING_ERROR = "Market Kurly goods page did not include __NEXT_DATA__."
function toNumberOrNull(value) {
if (value === undefined || value === null || value === "") {
return null
}
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function normalizeKeyword(query) {
const normalized = String(query || "").trim()
if (!normalized) {
throw new Error("keyword is required.")
}
return normalized
}
function normalizeProductNo(productNo) {
const normalized = String(productNo || "").trim()
if (!normalized) {
throw new Error("productNo is required.")
}
const numeric = Number(normalized)
return Number.isFinite(numeric) ? numeric : normalized
}
function buildGoodsUrl(productNo) {
return `${KURLY_WEB_BASE_URL}/goods/${productNo}`
}
function firstPresent(...values) {
for (const value of values) {
if (value !== undefined && value !== null && value !== "") {
return value
}
}
return null
}
function normalizeDeliveryTypes(value) {
if (!Array.isArray(value)) {
return []
}
return value.map((item) => String(item || "").trim()).filter(Boolean)
}
function normalizeSearchItem(item) {
const productNo = normalizeProductNo(item?.no)
const salesPrice = toNumberOrNull(item?.salesPrice)
const discountedPrice = toNumberOrNull(item?.discountedPrice)
const basePrice = toNumberOrNull(item?.basePrice)
const currentPrice = firstPresent(discountedPrice, salesPrice, basePrice)
const originalPrice = firstPresent(salesPrice, basePrice, currentPrice)
return {
productNo,
name: String(item?.name || ""),
shortDescription: item?.shortDescription || null,
currentPrice,
originalPrice,
salesPrice,
discountedPrice,
discountRate: toNumberOrNull(item?.discountRate),
isSoldOut: Boolean(item?.isSoldOut),
isPurchaseStatus: item?.isPurchaseStatus ?? null,
deliveryTypeNames: normalizeDeliveryTypes(item?.deliveryTypeNames),
reviewCount: toNumberOrNull(item?.reviewCount),
imageUrl: item?.listImageUrl || item?.imageUrl || null,
goodsUrl: buildGoodsUrl(productNo),
raw: item
}
}
function collectSearchItems(payload) {
const sections = Array.isArray(payload?.data?.listSections) ? payload.data.listSections : []
const items = []
for (const section of sections) {
if (Array.isArray(section?.data?.items)) {
items.push(...section.data.items)
}
}
return items
}
function normalizeSearchResponse(payload, query) {
const items = collectSearchItems(payload)
if (items.length === 0) {
throw new Error(SEARCH_EMPTY_RESULT_ERROR)
}
return {
query: normalizeKeyword(query),
pagination: payload?.data?.meta?.pagination || null,
items: items.map(normalizeSearchItem)
}
}
function normalizeCountResponse(payload, query) {
const count = toNumberOrNull(payload?.data?.count)
if (count === null) {
throw new Error(COUNT_EMPTY_RESULT_ERROR)
}
return {
query: normalizeKeyword(query),
count
}
}
function extractNextDataJson(html) {
const match = String(html || "").match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/u)
if (!match) {
throw new Error(NEXT_DATA_MISSING_ERROR)
}
return JSON.parse(match[1])
}
function hasDetailShape(candidate) {
return Boolean(candidate) && typeof candidate === "object" && "name" in candidate && "isSoldOut" in candidate && "deliveryTypeNames" in candidate && ("no" in candidate || "productNo" in candidate)
}
function normalizeDetailCandidate(candidate) {
const productNo = normalizeProductNo(firstPresent(candidate.productNo, candidate.no))
const basePrice = toNumberOrNull(candidate.basePrice)
const salesPrice = toNumberOrNull(candidate.salesPrice)
const discountedPrice = toNumberOrNull(candidate.discountedPrice)
const currentPrice = firstPresent(discountedPrice, salesPrice, basePrice)
const originalPrice = firstPresent(salesPrice, basePrice, currentPrice)
return {
productNo,
name: String(candidate.name || ""),
shortDescription: candidate.shortDescription || null,
currentPrice,
originalPrice,
basePrice,
salesPrice,
discountedPrice,
discountRate: toNumberOrNull(candidate.discountRate),
isSoldOut: Boolean(candidate.isSoldOut),
deliveryTypeNames: normalizeDeliveryTypes(candidate.deliveryTypeNames),
imageUrl: firstPresent(candidate.imageUrl, candidate.listImageUrl, candidate.productVerticalMediumUrl),
goodsUrl: buildGoodsUrl(productNo),
raw: candidate
}
}
function findProductDetail(nextData) {
const stack = [nextData]
while (stack.length > 0) {
const current = stack.pop()
if (Array.isArray(current)) {
stack.push(...current)
continue
}
if (!current || typeof current !== "object") {
continue
}
if (hasDetailShape(current)) {
return normalizeDetailCandidate(current)
}
stack.push(...Object.values(current))
}
throw new Error(DETAIL_EMPTY_RESULT_ERROR)
}
module.exports = {
COUNT_EMPTY_RESULT_ERROR,
DETAIL_EMPTY_RESULT_ERROR,
KURLY_WEB_BASE_URL,
NEXT_DATA_MISSING_ERROR,
SEARCH_EMPTY_RESULT_ERROR,
buildGoodsUrl,
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeKeyword,
normalizeProductNo,
normalizeSearchResponse
}

View file

@ -0,0 +1,186 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { countProducts, getProductDetail, searchProducts } = require("../src/index")
const {
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeSearchResponse
} = require("../src/parse")
const searchPayload = {
success: true,
message: null,
data: {
meta: {
pagination: {
total: 2,
count: 2,
perPage: 96,
currentPage: 1,
totalPages: 1
},
actualKeyword: "딸기"
},
listSections: [
{
view: {
sectionCode: "PRODUCT_LIST",
version: "v1"
},
data: {
items: [
{
no: 5048935,
name: "금실 딸기 2종",
shortDescription: "새콤달콤 제철 딸기",
listImageUrl: "https://product-image.kurly.com/example-1.jpg",
salesPrice: 13900,
discountedPrice: 9900,
discountRate: 28.0,
isSoldOut: false,
deliveryTypeNames: ["샛별배송"],
reviewCount: 321,
isPurchaseStatus: true
},
{
no: 1234,
name: "냉동 딸기 1kg",
shortDescription: "스무디용 냉동 딸기",
listImageUrl: "https://product-image.kurly.com/example-2.jpg",
salesPrice: 8900,
discountedPrice: null,
discountRate: 0,
isSoldOut: true,
deliveryTypeNames: ["택배배송"],
reviewCount: 12,
isPurchaseStatus: false
}
]
}
}
]
}
}
const countPayload = {
data: {
count: 468
}
}
const detailHtml = `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({
props: {
pageProps: {
product: {
no: 5063110,
name: "[연세우유 x 마켓컬리] 전용목장우유 900mL",
shortDescription: "가격, 퀄리티 모두 만족스러운 1A등급 우유",
basePrice: 2780,
salesPrice: 2780,
discountedPrice: null,
discountRate: 0,
isSoldOut: false,
deliveryTypeNames: ["샛별배송(내일 아침)"],
imageUrl: "https://product-image.kurly.com/example-detail.jpg"
}
}
}
})}</script></body></html>`
test("normalizeSearchResponse returns public Market Kurly product candidates", () => {
const result = normalizeSearchResponse(searchPayload, "딸기")
assert.equal(result.query, "딸기")
assert.equal(result.pagination.total, 2)
assert.equal(result.items[0].productNo, 5048935)
assert.equal(result.items[0].currentPrice, 9900)
assert.equal(result.items[0].originalPrice, 13900)
assert.equal(result.items[0].discountRate, 28)
assert.equal(result.items[0].isSoldOut, false)
assert.equal(result.items[0].goodsUrl, "https://www.kurly.com/goods/5048935")
assert.deepEqual(result.items[0].deliveryTypeNames, ["샛별배송"])
assert.equal(result.items[1].currentPrice, 8900)
})
test("normalizeCountResponse extracts the numeric result count", () => {
assert.deepEqual(normalizeCountResponse(countPayload, "우유"), {
query: "우유",
count: 468
})
})
test("extractNextDataJson and findProductDetail parse the goods page payload", () => {
const nextData = extractNextDataJson(detailHtml)
const detail = findProductDetail(nextData)
assert.equal(detail.productNo, 5063110)
assert.equal(detail.name, "[연세우유 x 마켓컬리] 전용목장우유 900mL")
assert.equal(detail.currentPrice, 2780)
assert.equal(detail.originalPrice, 2780)
assert.equal(detail.isSoldOut, false)
assert.deepEqual(detail.deliveryTypeNames, ["샛별배송(내일 아침)"])
assert.equal(detail.goodsUrl, "https://www.kurly.com/goods/5063110")
})
test("public client helpers consume injected fetch fixtures", async () => {
const originalFetch = global.fetch
const seen = []
global.fetch = async (url) => {
seen.push(String(url))
if (String(url).includes("/search/v4/sites/market/normal-search")) {
return makeJsonResponse(searchPayload)
}
if (String(url).includes("/search/v3/sites/market/normal-search/count")) {
return makeJsonResponse(countPayload)
}
if (String(url).includes("/goods/5063110")) {
return new Response(detailHtml, {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8"
}
})
}
return new Response("not found", { status: 404 })
}
try {
const searchResult = await searchProducts("딸기")
assert.equal(searchResult.items[0].productNo, 5048935)
const countResult = await countProducts("우유")
assert.equal(countResult.count, 468)
const detailResult = await getProductDetail(5063110)
assert.equal(detailResult.productNo, 5063110)
assert.equal(detailResult.currentPrice, 2780)
assert.ok(seen.some((url) => url.includes("keyword=%EB%94%B8%EA%B8%B0")))
assert.ok(seen.some((url) => url.includes("allow_replace=true")))
assert.ok(seen.some((url) => url.endsWith("/goods/5063110")))
} finally {
global.fetch = originalFetch
}
})
test("searchProducts validates the keyword before sending the request", async () => {
await assert.rejects(() => searchProducts(" "), /keyword is required\./)
await assert.rejects(() => countProducts(""), /keyword is required\./)
await assert.rejects(() => getProductDetail(""), /productNo is required\./)
})
function makeJsonResponse(payload) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8"
}
})
}

View file

@ -829,6 +829,54 @@ test("daiso-product-search docs record the shipped feature and official sources"
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("repository docs advertise the market-kurly-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "market-kurly-search.md");
const skillPath = path.join(repoRoot, "market-kurly-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/market-kurly-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected market-kurly-search/SKILL.md to exist");
assert.match(readme, /\| 마켓컬리 상품 조회 \|/);
assert.match(readme, /\[마켓컬리 상품 조회 가이드\]\(docs\/features\/market-kurly-search\.md\)/);
assert.match(install, /--skill market-kurly-search/);
assert.match(install, /npm install -g .* market-kurly-search/);
assert.match(roadmap, /마켓컬리 상품 조회 스킬 출시/);
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
assert.match(sources, /https:\/\/www\.kurly\.com\/goods\/5063110/);
});
test("market-kurly-search skill and docs describe the unauthenticated Kurly search and detail flow", () => {
const skill = read(path.join("market-kurly-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "market-kurly-search.md"));
assert.match(skill, /^name: market-kurly-search$/m);
assert.match(skill, /^description: .*마켓컬리.*상품.*가격.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
assert.match(doc, /api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
assert.match(doc, /www\.kurly\.com\/goods\/<productNo>|www\.kurly\.com\/goods\/5063110/);
assert.match(doc, /로그인 없이|비로그인/);
assert.match(doc, /현재 가격|할인/);
assert.match(doc, /품절 여부|판매 상태/);
assert.match(doc, /가격.*달라질 수|시점에 따라 달라질 수/u);
assert.match(doc, /주문|장바구니/);
assert.match(doc, /보수적으로|보수적/);
}
});
test("market-kurly-search package exposes reusable search/count/detail helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "market-kurly-search", "src", "index.js"));
assert.equal(typeof pkg.searchProducts, "function");
assert.equal(typeof pkg.countProducts, "function");
assert.equal(typeof pkg.getProductDetail, "function");
});
test("repository docs advertise the olive-young-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -996,6 +1044,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace market-kurly-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);