mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
ba45df42d6
commit
4ce29ae009
15 changed files with 875 additions and 2 deletions
5
.changeset/market-kurly-search-skill.md
Normal file
5
.changeset/market-kurly-search-skill.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
72
docs/features/market-kurly-search.md
Normal file
72
docs/features/market-kurly-search.md
Normal 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 기준으로는 마켓컬리 상품 검색과 가격 조회를 로그인 없이 구현할 수 있음** 을 다시 검증했습니다. 다만 이 표면은 웹 내부 사용 경로이므로 이후 스키마/헤더 요구사항이 바뀌면 수정이 필요할 수 있습니다.
|
||||
|
|
@ -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)"
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
- 마켓컬리 상품 조회 스킬 출시
|
||||
- 올리브영 검색 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 번개장터 검색 스킬 출시
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
market-kurly-search/SKILL.md
Normal file
135
market-kurly-search/SKILL.md
Normal 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
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
79
packages/market-kurly-search/README.md
Normal file
79
packages/market-kurly-search/README.md
Normal 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": [
|
||||
"샛별배송(내일 아침)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
32
packages/market-kurly-search/package.json
Normal file
32
packages/market-kurly-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
93
packages/market-kurly-search/src/index.js
Normal file
93
packages/market-kurly-search/src/index.js
Normal 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
|
||||
}
|
||||
204
packages/market-kurly-search/src/parse.js
Normal file
204
packages/market-kurly-search/src/parse.js
Normal 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
|
||||
}
|
||||
186
packages/market-kurly-search/test/index.test.js
Normal file
186
packages/market-kurly-search/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue