Enable repeatable used-car price lookups from a rental-company source

Issue #46 required surveying major Korean rental companies before implementation and then choosing the easiest stable provider. SK렌터카 다이렉트 exposes 타고BUY inventory in public Next.js page data, so the feature stays dependency-free while still supporting live repeated lookups and documented provider rationale.

Constraint: Must compare major Korean rental companies before implementation
Constraint: Must verify 10+ live lookups against a real provider surface
Rejected: 롯데오토옥션 as v1 provider | public list contract was unstable and legacy .do flows returned inconsistent or 404 pages
Rejected: 레드캡렌터카 as v1 provider | no public used-car inventory or API surface was found
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the v1 provider read-only and inventory-snapshot-based unless a stable documented public API is confirmed
Tested: npm run ci
Tested: Live 10-query run against https://www.skdirect.co.kr/tb at 2026-04-02T07:22:46Z
Tested: LSP diagnostics on affected files
Not-tested: Seller-specific detail drilldowns or non-SK providers
Related: #46
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-02 16:26:04 +09:00
commit 3bc3762195
16 changed files with 727 additions and 2 deletions

View file

@ -35,6 +35,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 가격 조회 | 공식 쿠팡 URL + 브라우저 캡처 HTML 기준으로 상품 후보/가격/리뷰를 정리하고 anti-bot 차단 여부를 probe | 브라우저 세션/HTML 캡처 권장 | [쿠팡 상품 가격 조회 가이드](docs/features/coupang-product-search.md) |
| 중고차 가격 조회 | 주요 렌터카 업체 비교 후 SK렌터카 다이렉트 타고BUY inventory snapshot 기준으로 인수가/월 렌트료 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
## 처음 시작하는 순서
@ -76,6 +77,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 가격 조회](docs/features/coupang-product-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [릴리스/배포 가이드](docs/releasing.md)
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.

View file

@ -0,0 +1,87 @@
# 중고차 가격 조회 가이드
## 이 기능으로 할 수 있는 일
- 주요 한국 렌터카 업체를 먼저 비교한 뒤 v1 공급자를 선택하기
- `SK렌터카 다이렉트 타고BUY` inventory snapshot 에서 차종별 중고차 가격 조회
- `인수가`, `월 렌트료`, `연식`, `주행거리`, `연료`, `변속기` 정리
- 같은 구조의 조회를 **최소 10회 이상** 반복해도 안정적으로 응답하는지 검증
## 먼저 알아둘 점
### 현재 공급자 선정 결과
이 저장소와 현재 세션에는 중고차 가격 조회용 전용 **MCP****Skill** 이 없어서, 먼저 대표 렌터카 업체의 공개 표면을 비교했다.
| 업체 | 점검한 공개 표면 | API / 크롤링 판단 | 선택 여부 |
| --- | --- | --- | --- |
| SK렌터카 | `https://www.skdirect.co.kr/tb` | 별도 공개 API 문서는 못 찾았지만, 공개 HTML 안 `__NEXT_DATA__``carListProd` inventory snapshot 이 들어 있다. 로그인 없이 반복 조회가 가능해 가장 구현이 쉽다. | 선택 |
| 롯데렌탈(롯데오토옥션) | `https://www.lotteautoauction.net/hp/pub/cmm/viewMain.do` | 공개 진입점은 열리지만 legacy `.do` 화면 중심이고, 공개 일반 매물 목록 계약을 안정적으로 고정하기 어려웠다. | 보류 |
| 레드캡렌터카 | `https://biz.redcap.co.kr/rent/` | business portal 만 확인되었고 공개 중고차 inventory 검색/API 표면을 찾지 못했다. | 보류 |
즉, v1 은 **SK렌터카 다이렉트 타고BUY** 를 사용한다.
## 입력값
- 차종/모델 키워드
- 예: `아반떼`
- 예: `현대 아반떼`
- 예: `K3`
- 예: `캐스퍼`
차종 키워드가 없으면 먼저 물어본다.
## 공식 표면
- SK direct 타고BUY inventory page: `https://www.skdirect.co.kr/tb`
## 기본 흐름
1. 차종 키워드를 받는다.
2. `https://www.skdirect.co.kr/tb` HTML 을 가져온다.
3. HTML 안의 `__NEXT_DATA__` JSON 에서 `carListProd` 를 읽는다.
4. 차종 키워드와 `maker/model/grade` 조합으로 필터링한다.
5. `인수가`, `월 렌트료`, `연식`, `주행거리`, `연료`, `변속기`를 정리한다.
6. 같은 차종이라도 재고가 변할 수 있으므로 snapshot 시점 기준 결과라고 답한다.
## Node.js 예시
```js
const { lookupUsedCarPrices } = require("used-car-price-search")
async function main() {
const result = await lookupUsedCarPrices("K3", { limit: 3 })
console.log({
provider: result.provider,
matchedCount: result.matchedCount,
summary: result.summary,
items: result.items
})
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 응답 예시 포맷
- 공급자: `SK렌터카 다이렉트 타고BUY`
- 검색어: `아반떼`
- 매칭 수: `N대`
- 인수가 범위: `1,290만원 ~ 1,590만원`
- 월 렌트료 범위: `39.2만원 ~ 44.1만원`
- 대표 매물: 연식 / 주행거리 / 연료 / 변속기 순으로 2~5대
## 구현 메모
- 별도의 공개 REST API 문서는 확인하지 못했다.
- 대신 공개 HTML 에 들어 있는 `__NEXT_DATA__` inventory snapshot 을 읽는 방식이라 anti-bot 우회나 로그인 세션 없이도 동작한다.
- v1 은 차종 검색과 가격 요약에 집중하고, 계약/상담/결제 자동화는 하지 않는다.
## 라이브 검증 메모
2026-04-02 기준 `https://www.skdirect.co.kr/tb` 는 실제 inventory snapshot 총 `333대`를 반환했고, `캐스퍼`, `K3`, `티볼리`, `아반떼`, `쏘나타`, `투싼`, `싼타페`, `QM6`, `그랜저`, `스포티지` 순으로 **10회** 차종 조회를 반복해도 구조화된 결과를 계속 얻을 수 있었다.

View file

@ -57,7 +57,8 @@ npx --yes skills add <owner/repo> \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \
--skill coupang-product-search
--skill coupang-product-search \
--skill used-car-price-search
```
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.

View file

@ -19,6 +19,7 @@
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 쿠팡 상품 가격 조회 스킬 출시
- 중고차 가격 조회 스킬 출시
## v1.5 candidates

View file

@ -42,3 +42,7 @@
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail
- 우체국 배송조회: https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1=
- 우체국 배송상세 HTML: https://service.epost.go.kr/trace.RetrieveDomRigiTraceList.comm
- SK렌터카 다이렉트 타고BUY inventory page: https://www.skdirect.co.kr/tb
- 롯데오토옥션 공개 메인: https://www.lotteautoauction.net/hp/pub/cmm/viewMain.do
- 레드캡렌터카 business rent portal: https://biz.redcap.co.kr/rent/

11
package-lock.json generated
View file

@ -1597,6 +1597,10 @@
"node": ">= 4.0.0"
}
},
"node_modules/used-car-price-search": {
"resolved": "packages/used-car-price-search",
"link": true
},
"node_modules/which": {
"version": "2.0.2",
"dev": true,
@ -1669,6 +1673,13 @@
"engines": {
"node": ">=18"
}
},
"packages/used-car-price-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
}
}
}

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && 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 coupang-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace coupang-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --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,18 @@
# used-car-price-search
SK렌터카 다이렉트 `타고BUY` 페이지(`https://www.skdirect.co.kr/tb`)에 공개된 inventory snapshot 을 읽어 중고차 가격/인수가를 조회하는 Node.js helper 입니다.
## API
```js
const { lookupUsedCarPrices } = require("used-car-price-search")
```
- `fetchUsedCarInventory(options?)`
- `lookupUsedCarPrices(query, options?)`
## Notes
- 공개 HTML 안의 `__NEXT_DATA__` 를 읽는 방식이라 별도 로그인이나 비공개 API key 가 필요하지 않습니다.
- 결과는 `월 렌트료``인수가`를 함께 노출합니다.
- 검색은 현재 inventory snapshot 기준 키워드 매칭입니다.

View file

@ -0,0 +1,32 @@
{
"name": "used-car-price-search",
"version": "0.1.0",
"description": "SK렌터카 다이렉트 타고BUY 기반 중고차 가격 조회 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",
"korea",
"used-car",
"sk-rent-a-car",
"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,66 @@
const {
SK_DIRECT_USED_CAR_URL,
filterCarsByQuery,
normalizeUsedCarInventory,
summarizeMatches
} = require("./parse")
const DEFAULT_BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"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 requestText(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, {
headers: {
...DEFAULT_BROWSER_HEADERS,
...(options.headers || {})
},
signal: options.signal
})
if (!response.ok) {
throw new Error(`SK direct request failed with ${response.status} for ${url}`)
}
return response.text()
}
async function fetchUsedCarInventory(options = {}) {
const html = await requestText(options.url || SK_DIRECT_USED_CAR_URL, options)
const inventory = normalizeUsedCarInventory(html)
return {
...inventory,
fetchedAt: new Date().toISOString()
}
}
async function lookupUsedCarPrices(query, options = {}) {
const limit = Number(options.limit || 10)
const inventory = await fetchUsedCarInventory(options)
const matches = filterCarsByQuery(inventory.items, query).slice(0, limit)
return {
provider: inventory.provider,
fetchedAt: inventory.fetchedAt,
query: String(query || "").trim(),
totalInventory: inventory.total,
matchedCount: matches.length,
summary: summarizeMatches(matches),
items: matches
}
}
module.exports = {
fetchUsedCarInventory,
lookupUsedCarPrices
}

View file

@ -0,0 +1,198 @@
const SK_DIRECT_USED_CAR_URL = "https://www.skdirect.co.kr/tb"
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/
function extractNextData(html) {
const source = String(html || "")
const match = source.match(NEXT_DATA_PATTERN)
if (!match) {
throw new Error("Unable to locate SK direct __NEXT_DATA__ inventory payload.")
}
return JSON.parse(match[1])
}
function normalizeUsedCarInventory(input) {
const nextData = typeof input === "string" ? extractNextData(input) : input
const carList = nextData?.props?.pageProps?.carListProd
if (!Array.isArray(carList)) {
throw new Error("Expected carListProd in the SK direct inventory payload.")
}
const items = carList.map(normalizeCar).sort(compareCars)
return {
provider: {
name: "SK렌터카 다이렉트 타고BUY",
siteUrl: SK_DIRECT_USED_CAR_URL,
inventoryPath: "/tb",
extraction: "next-data"
},
total: items.length,
items
}
}
function normalizeCar(raw) {
const maker = cleanText(raw.carMakerNm)
const model = cleanText(raw.modeProdNm || raw.cartypeNm)
const carType = cleanText(raw.cartypeNm)
const grade = cleanText(raw.carGradeNm)
const trim = cleanText(raw.crtrClsNm1)
const color = cleanText(raw.colorNm)
const displayName = uniqueJoin([maker, model, grade])
const searchText = uniqueJoin([maker, carType, model, grade, trim, color])
return {
id: cleanText(raw.prodId),
providerProductClass: cleanText(raw.prodClsNm),
maker,
model,
displayName,
color,
monthlyPrice: toNumber(raw.realPaymentAmt),
buyoutPrice: toNumber(raw.tkvAmt),
buyoutPriceManwon: toManwonRounded(raw.tkvAmt),
mileageKm: toNumber(raw.travelDtc),
fuel: cleanText(raw.fuelNm),
transmission: cleanText(raw.grbxNm),
seats: toNumber(raw.seaterClsNm),
registrationYearMonth: toYearMonth(raw.carRegDt),
modelYear: toNumber(raw.yearType),
stock: toNumber(raw.prodStock),
imageUrl: cleanText(raw.repCarImg),
searchText
}
}
function summarizeMatches(items) {
if (!Array.isArray(items) || items.length === 0) {
return null
}
return {
count: items.length,
monthlyPriceMin: minValue(items, "monthlyPrice"),
monthlyPriceMax: maxValue(items, "monthlyPrice"),
buyoutPriceMin: minValue(items, "buyoutPrice"),
buyoutPriceMax: maxValue(items, "buyoutPrice"),
mileageKmMin: minValue(items, "mileageKm"),
mileageKmMax: maxValue(items, "mileageKm")
}
}
function filterCarsByQuery(items, query) {
const queryText = cleanText(query)
if (!queryText) {
throw new Error("query is required.")
}
const rawTokens = queryText.split(/\s+/).map(normalizeSearchKey).filter(Boolean)
const fullQueryKey = normalizeSearchKey(queryText)
return items
.filter((item) => {
const haystack = normalizeSearchKey(item.searchText)
return rawTokens.every((token) => haystack.includes(token))
})
.map((item) => ({
item,
score: computeMatchScore(item, fullQueryKey, rawTokens)
}))
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score
}
return compareCars(left.item, right.item)
})
.map((entry) => entry.item)
}
function computeMatchScore(item, fullQueryKey, rawTokens) {
const modelKey = normalizeSearchKey(item.model)
const displayKey = normalizeSearchKey(item.displayName)
const haystack = normalizeSearchKey(item.searchText)
let score = 0
if (modelKey === fullQueryKey) {
score += 10
}
if (displayKey.includes(fullQueryKey)) {
score += 5
}
if (haystack.includes(fullQueryKey)) {
score += 3
}
score += rawTokens.filter((token) => modelKey.includes(token)).length * 2
score += rawTokens.filter((token) => displayKey.includes(token)).length
return score
}
function compareCars(left, right) {
return (
compareNumbers(left.buyoutPrice, right.buyoutPrice) ||
compareNumbers(left.monthlyPrice, right.monthlyPrice) ||
compareNumbers(left.mileageKm, right.mileageKm) ||
String(left.displayName).localeCompare(String(right.displayName), "ko")
)
}
function compareNumbers(left, right) {
return Number(left || 0) - Number(right || 0)
}
function minValue(items, key) {
return Math.min(...items.map((item) => Number(item[key] || 0)))
}
function maxValue(items, key) {
return Math.max(...items.map((item) => Number(item[key] || 0)))
}
function toYearMonth(value) {
const digits = String(value || "").replace(/\D/g, "")
if (digits.length < 6) {
return ""
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}`
}
function toManwonRounded(value) {
const amount = toNumber(value)
return amount ? Math.round(amount / 10000) : 0
}
function toNumber(value) {
const amount = Number(String(value ?? "").replace(/,/g, ""))
return Number.isFinite(amount) ? amount : 0
}
function normalizeSearchKey(value) {
return cleanText(value)
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "")
}
function cleanText(value) {
return String(value || "").replace(/\s+/g, " ").trim()
}
function uniqueJoin(parts) {
return [...new Set(parts.map(cleanText).filter(Boolean))].join(" ")
}
module.exports = {
SK_DIRECT_USED_CAR_URL,
extractNextData,
filterCarsByQuery,
normalizeUsedCarInventory,
summarizeMatches
}

View file

@ -0,0 +1,2 @@
<!DOCTYPE html>
<html><body><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"carListProd":[]}},"page":"/pc/tb","query":{},"buildId":"test-build"}</script></body></html>

View file

@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="ko">
<head><title>타고BUY</title></head>
<body>
<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"carListProd":[{"prodClsCd":"A78004","prodClsNm":"타고BUY","prodId":"MP0000099027","carMakerNm":"현대","cartypeNm":"캐스퍼","modeProdNm":"캐스퍼","carGradeNm":"1.0 가솔린 모던","realPaymentAmt":350100,"travelDtc":"64581","colorNm":"아틀라스 화이트","prodStock":1,"atbgPrdYr":"2023","fuelNm":"휘발유","seaterClsNm":"4","grbxNm":"오토","tkvAmt":"10466815","yearType":"2022","carAge":"42","carRegDt":"20221102","crtrClsNm1":"모던","drivWayDtlNm":"2WD","repCarImg":"https://image.skrentok.com/example/casper.jpg"},{"prodClsCd":"A78004","prodClsNm":"타고BUY","prodId":"MP0000100001","carMakerNm":"현대","cartypeNm":"더 뉴 아반떼 (CN7)","modeProdNm":"아반떼","carGradeNm":"1.6 가솔린 스마트","realPaymentAmt":392100,"travelDtc":"61931","colorNm":"사이버 그레이","prodStock":1,"atbgPrdYr":"2023","fuelNm":"가솔린","seaterClsNm":"5","grbxNm":"오토","tkvAmt":"12900000","yearType":"2022","carAge":"38","carRegDt":"20230315","crtrClsNm1":"스마트","drivWayDtlNm":"2WD","repCarImg":"https://image.skrentok.com/example/avante.jpg"},{"prodClsCd":"A78004","prodClsNm":"타고BUY","prodId":"MP0000103355","carMakerNm":"기아","cartypeNm":"더 뉴 K3 2세대","modeProdNm":"K3","carGradeNm":"1.6 가솔린 프레스티지","realPaymentAmt":368100,"travelDtc":"100570","colorNm":"스노우 화이트 펄","prodStock":1,"atbgPrdYr":"2022","fuelNm":"가솔린","seaterClsNm":"5","grbxNm":"오토","tkvAmt":"12240000","yearType":"2021","carAge":"48","carRegDt":"20220624","crtrClsNm1":"프레스티지","drivWayDtlNm":"2WD","repCarImg":"https://image.skrentok.com/example/k3.jpg"}],"eventList":[],"bannerList":[]}},"page":"/pc/tb","query":{},"buildId":"test-build"}</script>
</body>
</html>

View file

@ -0,0 +1,138 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const {
fetchUsedCarInventory,
lookupUsedCarPrices
} = require("../src/index")
const {
extractNextData,
normalizeUsedCarInventory,
summarizeMatches
} = require("../src/parse")
const fixturesDir = path.join(__dirname, "fixtures")
const inventoryHtml = fs.readFileSync(path.join(fixturesDir, "tb-page.html"), "utf8")
const emptyInventoryHtml = fs.readFileSync(path.join(fixturesDir, "tb-empty.html"), "utf8")
test("extractNextData reads the official Next.js inventory payload from SK direct HTML", () => {
const nextData = extractNextData(inventoryHtml)
assert.equal(nextData.page, "/pc/tb")
assert.equal(nextData.props.pageProps.carListProd.length, 3)
})
test("normalizeUsedCarInventory exposes public used-car price fields", () => {
const inventory = normalizeUsedCarInventory(inventoryHtml)
assert.equal(inventory.provider.name, "SK렌터카 다이렉트 타고BUY")
assert.equal(inventory.total, 3)
assert.deepEqual(inventory.items[0], {
id: "MP0000099027",
providerProductClass: "타고BUY",
maker: "현대",
model: "캐스퍼",
displayName: "현대 캐스퍼 1.0 가솔린 모던",
color: "아틀라스 화이트",
monthlyPrice: 350100,
buyoutPrice: 10466815,
buyoutPriceManwon: 1047,
mileageKm: 64581,
fuel: "휘발유",
transmission: "오토",
seats: 4,
registrationYearMonth: "2022-11",
modelYear: 2022,
stock: 1,
imageUrl: "https://image.skrentok.com/example/casper.jpg",
searchText: "현대 캐스퍼 1.0 가솔린 모던 모던 아틀라스 화이트"
})
})
test("summarizeMatches calculates price bands for matched cars", () => {
const inventory = normalizeUsedCarInventory(inventoryHtml)
const summary = summarizeMatches(inventory.items)
assert.deepEqual(summary, {
count: 3,
monthlyPriceMin: 350100,
monthlyPriceMax: 392100,
buyoutPriceMin: 10466815,
buyoutPriceMax: 12900000,
mileageKmMin: 61931,
mileageKmMax: 100570
})
})
test("lookupUsedCarPrices filters the inventory by car keyword and sorts the cheapest buyout first", async () => {
const originalFetch = global.fetch
global.fetch = async () => makeHtmlResponse(inventoryHtml)
try {
const result = await lookupUsedCarPrices("현대 아반떼", { limit: 5 })
assert.equal(result.query, "현대 아반떼")
assert.equal(result.matchedCount, 1)
assert.equal(result.items[0].model, "아반떼")
assert.equal(result.items[0].buyoutPrice, 12900000)
assert.deepEqual(result.summary, {
count: 1,
monthlyPriceMin: 392100,
monthlyPriceMax: 392100,
buyoutPriceMin: 12900000,
buyoutPriceMax: 12900000,
mileageKmMin: 61931,
mileageKmMax: 61931
})
} finally {
global.fetch = originalFetch
}
})
test("lookupUsedCarPrices returns a matched K3 result and a conservative empty result when nothing matches", async () => {
const originalFetch = global.fetch
global.fetch = async () => makeHtmlResponse(inventoryHtml)
try {
const k3 = await lookupUsedCarPrices("K3", { limit: 5 })
assert.equal(k3.matchedCount, 1)
assert.equal(k3.items[0].maker, "기아")
const nothing = await lookupUsedCarPrices("쏘렌토", { limit: 5 })
assert.equal(nothing.matchedCount, 0)
assert.deepEqual(nothing.items, [])
assert.equal(nothing.summary, null)
} finally {
global.fetch = originalFetch
}
})
test("fetchUsedCarInventory uses the official 타고BUY page and tolerates an empty inventory snapshot", async () => {
const originalFetch = global.fetch
let requestedUrl = null
global.fetch = async (url) => {
requestedUrl = String(url)
return makeHtmlResponse(emptyInventoryHtml)
}
try {
const inventory = await fetchUsedCarInventory()
assert.equal(requestedUrl, "https://www.skdirect.co.kr/tb")
assert.equal(inventory.total, 0)
assert.deepEqual(inventory.items, [])
} finally {
global.fetch = originalFetch
}
})
function makeHtmlResponse(body) {
return new Response(body, {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8"
}
})
}

View file

@ -166,6 +166,44 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
assert.match(install, /--skill kakaotalk-mac/);
});
test("repository docs advertise the used-car-price-search skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "used-car-price-search.md");
const skillPath = path.join(repoRoot, "used-car-price-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/used-car-price-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected used-car-price-search/SKILL.md to exist");
assert.match(readme, /\| 중고차 가격 조회 \|/);
assert.match(readme, /\[중고차 가격 조회 가이드\]\(docs\/features\/used-car-price-search\.md\)/);
assert.match(install, /--skill used-car-price-search/);
});
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
const skill = read(path.join("used-car-price-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /SK렌터카|SK렌터카 다이렉트|타고BUY/);
assert.match(doc, /롯데렌탈|롯데오토옥션/);
assert.match(doc, /레드캡렌터카/);
assert.match(doc, /MCP/i);
assert.match(doc, /Skill/i);
assert.match(doc, /https:\/\/www\.skdirect\.co\.kr\/tb/);
assert.match(doc, /__NEXT_DATA__/);
assert.match(doc, /인수가/);
assert.match(doc, /월\s*렌트료|월\s*요금|월\s*가격/);
assert.match(doc, /10회 이상|최소 10회/);
}
assert.match(sources, /https:\/\/www\.skdirect\.co\.kr\/tb/);
assert.match(sources, /https:\/\/www\.lotteautoauction\.net\/hp\/pub\/cmm\/viewMain\.do/);
assert.match(sources, /https:\/\/biz\.redcap\.co\.kr\/rent\//);
assert.match(roadmap, /중고차 가격 조회 스킬 출시/);
});
test("seoul subway docs require an explicit proxy until the hosted route is live", () => {
const readme = read("README.md");
const setup = read(path.join("docs", "setup.md"));
@ -984,6 +1022,7 @@ test("pack:dry-run includes the toss-securities workspace", () => {
const packageJson = JSON.parse(read("package.json"));
assert.match(packageJson.scripts["pack:dry-run"], /workspace toss-securities/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace used-car-price-search/);
});
test("package-lock captures the toss-securities workspace metadata for npm ci", () => {

View file

@ -0,0 +1,119 @@
---
name: used-car-price-search
description: 주요 한국 렌터카 업체를 비교한 뒤 SK렌터카 다이렉트 타고BUY inventory snapshot 으로 중고차 가격/인수가를 조회한다.
license: MIT
metadata:
category: automotive
locale: ko-KR
phase: v1
---
# Used Car Price Search
## What this skill does
한국의 대표 렌터카 업체를 먼저 비교하고, 현재는 **가장 기술적으로 구현이 쉬운 공급자**로 확인된 `SK렌터카 다이렉트 타고BUY`를 사용해 중고차 가격을 조회한다.
- 한국의 주요 렌터카 업체로 `SK렌터카`, `롯데렌탈(롯데오토옥션)`, `레드캡렌터카`를 먼저 확인한다.
- 각 업체의 공개 표면에서 **직접 API 제공 여부**, 웹 크롤링 난이도, 기존 **MCP / Skill** 존재 여부를 먼저 점검한다.
- 이 저장소와 현재 세션에는 중고차 가격 조회용 전용 **MCP****Skill** 이 없으므로 새 스킬이 직접 조회를 담당한다.
- 최종 선택 공급자는 `https://www.skdirect.co.kr/tb` 이다.
- 이 페이지는 로그인 없이 열리고, HTML 안의 `__NEXT_DATA__` 에 현재 inventory snapshot 이 들어 있어 반복 조회가 쉽다.
- 결과는 **월 렌트료**와 **인수가**를 함께 보여 준다.
## Provider survey
| 업체 | 점검 결과 | v1 채택 여부 |
| --- | --- | --- |
| SK렌터카 다이렉트 `타고BUY` | `https://www.skdirect.co.kr/tb` 공개 HTML 에 `__NEXT_DATA__` inventory snapshot 포함. 로그인/세션 없이 반복 조회 가능. 별도 공개 API 문서는 못 찾았지만 SSR 데이터 추출이 가장 단순함. | 채택 |
| 롯데렌탈 / 롯데오토옥션 | `https://www.lotteautoauction.net/hp/pub/cmm/viewMain.do` 공개 진입점은 열리지만, 일반 매물 검색은 legacy `.do` 흐름 중심이고 공개 목록 계약이 불명확했다. 추정 목록 URL은 404/에러 페이지가 섞여 v1 공급자로는 불안정했다. | 미채택 |
| 레드캡렌터카 | 공식 진입점이 `https://biz.redcap.co.kr/rent/` business portal 로 이어졌고, 공개 중고차 inventory 검색 표면이나 직접 API를 확인하지 못했다. | 미채택 |
## When to use
- "아반떼 중고차 가격 봐줘"
- "SK렌터카 타고BUY에 K3 얼마야?"
- "캐스퍼 인수가/월 렌트료 같이 알려줘"
- "중고차 시세를 렌터카 업체 기준으로 보고 싶어"
## When not to use
- 실제 구매/계약/상담 신청까지 자동화해야 하는 경우
- 특정 VIN/성능기록부/사고이력 원문까지 강제해야 하는 경우
- 여러 업체 통합 최저가 비교가 필요한 경우
## Prerequisites
- 인터넷 연결
- `node` 18+
- `used-car-price-search` package 또는 동일 로직
## Required inputs
### 1. Ask the car model/keyword first if it is missing
차종 키워드가 없으면 먼저 물어본다.
- 권장 질문: `어떤 차종을 찾을까요? 예: 아반떼, K3, 캐스퍼`
- 너무 넓으면: `제조사나 차종을 조금 더 구체적으로 알려주세요. 예: 현대 아반떼, 기아 K3`
## Official surface used in v1
- SK direct used-car inventory page: `https://www.skdirect.co.kr/tb`
## Workflow
1. 차종 키워드가 없으면 먼저 질문한다.
2. `SK렌터카`, `롯데렌탈`, `레드캡렌터카` 비교 결과를 짧게 기억하고, 현재 공급자는 `SK렌터카 다이렉트 타고BUY` 임을 유지한다.
3. `https://www.skdirect.co.kr/tb` HTML 을 가져온다.
4. HTML 의 `__NEXT_DATA__` JSON 에서 `carListProd` inventory snapshot 을 읽는다.
5. 차종 키워드로 inventory 를 필터링한다.
6. 상위 결과에서 `인수가`, `월 렌트료`, `연식`, `주행거리`, `연료`, `변속기`를 정리한다.
7. 같은 차종이라도 재고가 수시로 바뀔 수 있으므로 snapshot 기준 응답임을 짧게 알린다.
## Node.js example
```js
const { lookupUsedCarPrices } = require("used-car-price-search")
async function main() {
const result = await lookupUsedCarPrices("아반떼", { limit: 5 })
console.log({
provider: result.provider,
matchedCount: result.matchedCount,
summary: result.summary,
items: result.items
})
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## Respond conservatively
응답은 아래 순서로 짧게 정리한다.
- 공급자: `SK렌터카 다이렉트 타고BUY`
- 차종 키워드
- 매칭된 차량 수
- 인수가 범위
- 월 렌트료 범위
- 대표 차량 2~5개
- `공개 inventory snapshot 기준이라 실시간 재고/가격은 바뀔 수 있다`는 안내
## Done when
- 주요 렌터카 업체 비교와 공급자 선택 이유를 설명했다.
- 차종 키워드 기준으로 결과를 최소 1건 이상 또는 보수적 빈 결과로 반환했다.
- 결과에 `인수가``월 렌트료` 를 함께 담았다.
- 라이브 검증에서 **최소 10회 이상** 반복 조회가 가능함을 확인했다.
## Failure modes
- 공개 inventory snapshot 은 페이지 갱신 타이밍에 따라 달라질 수 있다.
- 별도 공개 API 문서는 찾지 못했으므로 v1 은 HTML 내 `__NEXT_DATA__` 의 안정성에 의존한다.
- 특정 차종 키워드가 너무 넓으면 유사 모델이 함께 섞일 수 있다.