mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
7cbe2b58ed
commit
3bc3762195
16 changed files with 727 additions and 2 deletions
|
|
@ -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` 실행 → 개별 기능 사용" 입니다.
|
||||
|
|
|
|||
87
docs/features/used-car-price-search.md
Normal file
87
docs/features/used-car-price-search.md
Normal 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회** 차종 조회를 반복해도 구조화된 결과를 계속 얻을 수 있었다.
|
||||
|
||||
|
|
@ -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` 은 같이 넣는다.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
- 쿠팡 상품 가격 조회 스킬 출시
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
|
|||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
18
packages/used-car-price-search/README.md
Normal file
18
packages/used-car-price-search/README.md
Normal 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 기준 키워드 매칭입니다.
|
||||
32
packages/used-car-price-search/package.json
Normal file
32
packages/used-car-price-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
66
packages/used-car-price-search/src/index.js
Normal file
66
packages/used-car-price-search/src/index.js
Normal 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
|
||||
}
|
||||
198
packages/used-car-price-search/src/parse.js
Normal file
198
packages/used-car-price-search/src/parse.js
Normal 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
|
||||
}
|
||||
2
packages/used-car-price-search/test/fixtures/tb-empty.html
vendored
Normal file
2
packages/used-car-price-search/test/fixtures/tb-empty.html
vendored
Normal 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>
|
||||
7
packages/used-car-price-search/test/fixtures/tb-page.html
vendored
Normal file
7
packages/used-car-price-search/test/fixtures/tb-page.html
vendored
Normal 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>
|
||||
138
packages/used-car-price-search/test/index.test.js
Normal file
138
packages/used-car-price-search/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
119
used-car-price-search/SKILL.md
Normal file
119
used-car-price-search/SKILL.md
Normal 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__` 의 안정성에 의존한다.
|
||||
- 특정 차종 키워드가 너무 넓으면 유사 모델이 함께 섞일 수 있다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue