mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Release: coupang, korean-law, subway proxy improvements (#43)
* Remove client-side Seoul subway key setup Route Seoul subway arrival lookups through k-skill-proxy so the hosted proxy owns the Seoul Open Data upstream key and end users only need the proxy base URL. Add proxy route coverage, update skill/docs guidance, and align setup materials with the hosted proxy flow used for fine dust. Constraint: Must keep the proxy public, read-only, and dependency-free Constraint: Must satisfy TDD-first verification and ship on feature/#35 targeting dev Rejected: Add a separate client helper package | unnecessary extra layer for a single proxy route Rejected: Keep SEOUL_OPEN_API_KEY as an end-user requirement | defeats the issue goal Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep the Seoul subway proxy surface limited to station-arrival passthrough unless tests/docs expand the contract Tested: npm run ci; local proxy runtime on 127.0.0.1:4120 for /health and /v1/seoul-subway/arrival on 2026-03-31 with an invalid upstream key Not-tested: Live success response with a valid Seoul Open API key Related: #35 * Prevent broken Seoul subway proxy defaults before hosted rollout The Seoul subway proxy endpoint code is present locally, but the hosted public route is not live yet. This change turns the user-facing subway docs back into an explicit proxy configuration flow, replaces the misleading hosted default in setup examples, and keeps subway proxy examples on self-host/local URLs until rollout is verified. Constraint: Hosted k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival is not live yet Rejected: Keep the hosted Seoul subway URL as the default path | would send default users to a 404 route Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not restore a hosted Seoul subway default until the public proxy route is deployed and smoke-verified Tested: node --test scripts/skill-docs.test.js; npm run ci; local proxy smoke on 127.0.0.1:4120 with stubbed Seoul upstream (GET /health, GET /v1/seoul-subway/arrival?stationName=강남) Not-tested: Live hosted proxy smoke after deployment * Reduce Seoul subway proxy upstream pressure with request caching The public subway arrival route already normalized caller input but still re-fetched the Seoul upstream for every identical poll. This change adds a short-TTL cache keyed from the normalized subway query, annotates JSON responses with cache metadata, and locks the repeated-read collapse with a regression that proves alias/default normalization still reuses the cached result. Constraint: The endpoint must stay public/no-auth while protecting a shared server-side SEOUL_OPEN_API_KEY from unnecessary repeated upstream hits Rejected: Add a new shared cache abstraction for proxy metadata | unnecessary for this narrow fix and would enlarge the diff Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep subway cache keys tied to normalizeSeoulSubwayQuery output so alias/default query forms continue collapsing to one upstream request Tested: node --test packages/k-skill-proxy/test/server.test.js Tested: npm run ci Tested: Local proxy runtime on 127.0.0.1:4120 with SEOUL_OPEN_API_KEY=test-seoul-key and stubbed fetch for /health plus repeated /v1/seoul-subway/arrival requests Not-tested: Live hosted proxy rollout state * Document OpenClaw support in the public compatibility list The approved scope for issue #29 was narrowed to README support messaging, so this change adds OpenClaw/ClawHub to the supported-client line and locks that wording with a regression test in the docs suite. Constraint: Issue #29 was approved as a README-only compatibility/docs change Rejected: Broader install-doc updates | out of approved scope for this issue Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep OpenClaw support wording aligned with the supported-client README line and its docs regression test Tested: Focused Node docs regression, npm run lint, npm run typecheck, npm run build, npm test Not-tested: Live OpenClaw or ClawHub install flow (issue scope was documentation-only) * Protect README client-support claims from ClawHub regressions The README already advertises OpenClaw/ClawHub, but the docs regression only matched OpenClaw. Tighten the assertion to the exact supported-client fragment so a future edit cannot silently remove ClawHub while keeping the issue verification command stable. Constraint: PR #39 already publishes a verification command keyed to the current test name Rejected: Rename the test to mention ClawHub explicitly | would drift from the published verification command Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep this regression synchronized with the README supported-client line whenever that copy changes Tested: node --test scripts/skill-docs.test.js --test-name-pattern 'README advertises OpenClaw among the supported coding agents' Tested: npm run lint Tested: npm run typecheck Tested: npm run build Tested: npm test Tested: lsp diagnostics for scripts/skill-docs.test.js (0 errors) Not-tested: N/A * Make Coupang shopper research usable without pretending scraping is stable Coupang blocks unattended shopper queries in this environment, so the new package focuses on the durable pieces we can ship honestly: official URL builders, browser-captured HTML parsers, and explicit automation probes. The docs and skill now explain the seller-API limitation, the verified anti-bot behavior, and the browser-capture fallback expected by callers. Constraint: No general shopper Open API surfaced in Coupang's official developer docs Constraint: Headless/direct retrieval is anti-bot blocked in verified local probes Rejected: Bundle a scraping bypass or hidden browser dependency | violates repo policy and would over-claim reliability Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep probe/docs behavior aligned with fresh live verification before claiming unattended Coupang access works Tested: npm run ci; live probeAutomation(query=생수) with direct fetch + Playwright-core browserFetchHtml; LSP diagnostics on changed JS/test files Not-tested: Headed/manual browser sessions with a human-authenticated Coupang context * Keep the Coupang workspace installable and its entrypoint honest Review follow-up found two contract gaps in the first issue #36 rollout: fresh installs were missing the new workspace link in package-lock, and the package README documented parser helpers that were not exported from the package entrypoint. Refreshing the lockfile and re-exporting the parsers keeps npm ci and consumer imports aligned with the published API. Constraint: npm ci must succeed from a clean checkout Rejected: Narrow the README API list to only client helpers | would hide useful parsers that the package already ships and tests Confidence: high Scope-risk: narrow Reversibility: clean Directive: When adding a new workspace, refresh package-lock and verify the package entrypoint matches README public API claims Tested: npm run ci; workspace coupang-product-search tests; LSP diagnostics on coupang-product-search JS/test files Not-tested: published npm install from registry (local pack dry-run only) * Keep Coupang anti-bot docs honest as probe results drift A same-day PR rerun showed the published mobile probe snapshot was already stale, so the follow-up locks the 403/access-denied mobile path with regression coverage and softens the docs/skill contract to describe blocked outcome classes instead of one frozen signature. The published guidance now also explains that browser results only appear when browserFetchHtml is injected, matching what a clean checkout can actually reproduce. Constraint: Coupang anti-bot responses vary by edge/challenge between reruns on the same day Rejected: Freeze one exact mobile probe snapshot | same-day reruns already diverged Confidence: high Scope-risk: narrow Reversibility: clean Directive: Refresh Coupang anti-bot docs only with same-day live probe evidence, and prefer blocked outcome classes over a single exact signature when reruns disagree Tested: npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch Not-tested: Non-Chrome Playwright-core executables * Keep the Coupang skill honest about clean-checkout browser probes The approved follow-up already documented that clean-checkout probeAutomation runs leave browser unset unless a browser fetcher is injected, but the skill text itself had not made that null contract explicit. This commit adds a regression test that locks the browser-null wording across the published docs surfaces and updates the skill so the stated behavior matches the package implementation and clean-checkout reality. Constraint: probeAutomation() only populates browser when browserFetchHtml is supplied by the caller Rejected: Leave the skill wording implicit and rely on README examples alone | the skill could drift away from the shipped package contract again Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the README, feature doc, and skill aligned whenever the probeAutomation browser contract changes Tested: node --test packages/coupang-product-search/test/index.test.js; npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch; codex exec review --uncommitted Not-tested: Alternative browser engines beyond local Google Chrome for the manual browserFetchHtml probe * Keep Coupang browser probe docs aligned with manual verification paths The approved PR #40 follow-up still had one wording gap: the skill said when browser results appear, but it did not explicitly say those populated results come from an injected manual/external browserFetchHtml path. This change locks that contract with a failing regression first, then updates the skill text to match the already-shipped README/feature-doc guidance and runtime behavior. Constraint: clean-checkout probeAutomation() must keep browser null unless browserFetchHtml is supplied Rejected: change runtime browser probe behavior | approved follow-up was docs/test only and runtime contract is already correct Confidence: high Scope-risk: narrow Reversibility: clean Directive: keep Coupang probe docs aligned with the shipped browserFetchHtml injection contract; do not imply built-in browser probing in clean checkouts Tested: node --test packages/coupang-product-search/test/index.test.js; npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch; lsp diagnostics on changed files; architect review approved Not-tested: alternate browser engines beyond local Google Chrome + playwright-core manual runner * Route Korean law lookups through korean-law-mcp Add a documentation-first korean-law-search skill and lock the repo docs around the rule that Korean law queries must go through korean-law-mcp rather than a new in-repo package. The change updates install/setup/security guidance, publishes the new feature doc, and adds regression tests so future edits keep the LAW_OC + korean-law-mcp contract intact. Constraint: Issue #41 requires korean-law-mcp for every Korean law lookup Constraint: Must not add a new npm or python package in this repository Rejected: Add a repo-local law package | violates the no-new-package requirement and duplicates upstream MCP work Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Korean law lookup guidance pinned to korean-law-mcp unless issue requirements explicitly change Tested: node --test scripts/skill-docs.test.js Tested: npm install -g korean-law-mcp && korean-law list && korean-law help search_law Tested: npm run ci Tested: npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json Not-tested: live search_law/get_law_text against a real LAW_OC credential * Clarify Korean law setup modes to avoid credential confusion A review found the new korean-law-search docs treated LAW_OC as an unconditional prerequisite even though the upstream remote MCP endpoint is configured separately from the local CLI/server path. This update makes the docs and regression tests mode-specific: local CLI/MCP uses LAW_OC, while the remote endpoint stays a korean-law-mcp-only url fallback without a user-supplied credential. Constraint: Upstream korean-law-mcp uses LAW_OC on the local CLI/server path while the documented remote MCP endpoint is configured with url only Rejected: Keep LAW_OC mandatory for every korean-law-mcp mode | contradicts upstream docs and reviewer evidence Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep README/setup/skill docs and regression tests aligned on the local-vs-remote korean-law-mcp contract Tested: node --test scripts/skill-docs.test.js; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm run ci; lsp_diagnostics scripts/skill-docs.test.js Not-tested: Live remote MCP endpoint connection against https://korean-law-mcp.fly.dev/mcp * Record issue #41 follow-up after approved verification The approved korean-law-search change was already present on feature/#41, so this follow-up records a fresh verification pass and updates the PR without introducing unnecessary code churn. Constraint: Existing branch already contained the approved implementation Rejected: Touch repo files without need | would create noise without improving behavior Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the korean-law-mcp-only + mode-specific LAW_OC contract aligned with upstream docs before changing these surfaces again Tested: node --test scripts/skill-docs.test.js; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npm run ci Not-tested: Live korean-law queries that require a real LAW_OC or remote endpoint session * Keep the Korean law feature diff merge-ready Verification uncovered trailing whitespace in the new korean-law feature guide when checking the branch diff. I added a regression to the skill docs suite and removed the whitespace so the approved documentation contract stays clean and reviewable. Constraint: Preserve the existing korean-law-mcp + mode-specific LAW_OC contract without widening scope Rejected: Leave the whitespace issue as-is | git diff --check stayed dirty on the branch Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep docs/features/korean-law-search.md free of trailing whitespace so diff hygiene stays enforced by the regression Tested: node --test scripts/skill-docs.test.js; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npm run ci; git diff --check Not-tested: Live credentialed LAW_OC search execution against the upstream API * Keep Korean law skill guidance aligned with supported lookups The approved issue #41 feature already worked, but the shipped skill text still under-described ordinance and interpretation lookups compared with the documented capability set. This follow-up tightens the skill copy and locks that contract with a doc regression so the branch stays merge-ready without changing the underlying korean-law-mcp routing rules. Constraint: Must preserve the existing korean-law-mcp-only and mode-specific LAW_OC setup contract Rejected: Leave the skill wording as-is | drift from the documented lookup surface would remain Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the skill examples and doc regression aligned whenever supported korean-law-mcp search surfaces change Tested: node --test scripts/skill-docs.test.js Not-tested: live LAW_OC-backed upstream API queries * Keep Korean law completion guidance in sync with enforced lookups The previous follow-up aligned the main skill examples, but the done-criteria text still left interpretation and ordinance routing implicit. This commit makes the completion checklist explicit and extends the doc regression so future edits cannot silently drop those lookup paths. Constraint: Must stay within the existing issue #41 korean-law-mcp-only contract Rejected: Leave the done checklist implicit | reviewers and future edits could drift from the enforced lookup set Confidence: high Scope-risk: narrow Reversibility: clean Directive: When the supported Korean-law lookup set changes, update the done checklist and regression together Tested: node --test scripts/skill-docs.test.js Not-tested: full CI before commit * 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 * Keep used-car-price-search releasable after merge Review feedback found that the new used-car workspace would not ship because it lacked a changeset, and the fallback install docs still omitted the runtime package. This follow-up adds regression coverage first, then restores both release and install-path coverage with the smallest possible diff. Constraint: New publishable workspaces must be wired through Changesets to reach npm release automation Constraint: Fallback install docs must list runtime packages users need when skill files are present but global packages are missing Rejected: Fix only the changeset gap | would leave the documented fallback install path incomplete Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep used-car-price-search in both the fallback global install example and a Changesets entry whenever release wiring changes Tested: node --test scripts/skill-docs.test.js; npx changeset status; live 10-query used-car run against SK direct at 2026-04-02T07:38:44.949Z; npm run ci; LSP diagnostics on used-car package files and scripts/skill-docs.test.js; architect verification Not-tested: No additional live provider permutations beyond the verified 10-query smoke run * Keep used-car verification docs resilient to live inventory churn A fresh rerun showed the SK direct inventory total continues to move during the day, so the feature doc now records the verified smoke-run timestamp without freezing a brittle exact count. The regression suite was updated first so the docs stay variability-aware and diff-clean in future follow-ups. Constraint: Live SK direct inventory totals change over time even when the parsing contract stays stable Rejected: Keep documenting a fixed total from one smoke run | it immediately drifted and made the docs stale Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the used-car live verification note timestamped and variability-aware instead of pinning an exact inventory total Tested: node --test scripts/skill-docs.test.js; git diff --check; npm run ci; npx changeset status; live 10-query run against https://www.skdirect.co.kr/tb at 2026-04-02T07:59:41.391Z; LSP diagnostics on scripts/skill-docs.test.js; architect verification Not-tested: No additional content changes outside the used-car feature doc * Keep used-car query summaries honest when results are limited The review found that query-level stats were being computed from the post-limit slice, so common searches like 아반떼 under-reported both match counts and price ranges. This change locks the regression first, then computes the full filtered set before slicing only the returned items. Constraint: PR #48 must preserve the existing API shape while fixing the review-blocking stats bug Rejected: Expanding the response with separate limited/full summary fields | unnecessary API churn for a targeted bug fix Confidence: high Scope-risk: narrow Reversibility: clean Directive: Query-level summary and matchedCount must stay derived from the full filtered set, not the display limit slice Tested: npm test --workspace used-car-price-search; npm run ci; git diff --check; npx changeset status; live SK direct smoke run on 2026-04-02T08:23:59Z; LSP diagnostics on used-car-price-search source and test files; architect verification APPROVED Not-tested: limit=0 semantics remain unchanged and still coerce to the default limit Related: PR #48 * Keep korean-law-search available during upstream outages (#45) Issue #44 adds Beopmang as the documented fallback when the primary korean-law-mcp path is unavailable, and locks the new routing into the doc regression suite so future edits do not silently revert the policy. Constraint: Existing guidance must still prefer korean-law-mcp and keep LAW_OC scoped to the local CLI/MCP path Rejected: Add a repo-local Beopmang client package | issue only requires fallback registration, not a new implementation surface Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the primary-first rule intact; Beopmang is an outage fallback, not the default path Tested: node --test scripts/skill-docs.test.js; npm run ci; korean-law list; python3 live Beopmang search smoke for 관세법 Not-tested: Live Beopmang MCP handshake from a GUI MCP client * Replace coupang scraping package with coupang-mcp server integration Drop the browser-based scraping package (packages/coupang-product-search) and switch to the uju777/coupang-mcp MCP server for all Coupang product searches. This removes the anti-bot workaround complexity and provides 8 ready-to-use tools via MCP Streamable HTTP with no API key required. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add disclaimer to used-car-price-search README Clarify that the package queries SK렌터카 타고BUY public data with no affiliation or sponsorship, and that ad/partnership inquiries are welcome. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Refactor used-car-price-search into provider-based architecture SK 타고BUY 전용 로직을 src/providers/sk-tagobuy.js로 분리하고, 공급자를 수평 확장할 수 있는 registry 구조로 전환한다. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bd6997d133
commit
51464d1806
33 changed files with 1975 additions and 50 deletions
5
.changeset/used-car-price-search-skill.md
Normal file
5
.changeset/used-car-price-search-skill.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"used-car-price-search": minor
|
||||
---
|
||||
|
||||
Publish the first reusable used-car-price-search package with the SK direct inventory parser and skill docs.
|
||||
10
README.md
10
README.md
|
|
@ -5,7 +5,7 @@
|
|||
한국인인가요? 이 스킬 모음집을 다운로드 받아 두세요. 언젠가 **무조건** 쓸 때가 옵니다!
|
||||
SRT, KTX, KBO, 로또, 당근, 쿠팡, 카톡, 정부24, 홈택스 등등 귀찮은 것을 AI 에이전트에게 다 시켜버리세요.
|
||||
|
||||
Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
||||
Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트 지원합니다.
|
||||
|
||||
추가 클라이언트 API 레이어는 불필요합니다. 필요한 경우 `k-skill-proxy` 같은 프록시 서버에 HTTP 요청만 넣으면 됩니다.
|
||||
|
||||
|
|
@ -21,8 +21,9 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
|||
| SRT 예매 | 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
|
||||
| KTX 예매 | Dynapath anti-bot 대응 helper 로 KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
|
||||
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 프록시 URL 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
|
||||
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||
|
|
@ -33,6 +34,8 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
|||
| 우편번호 검색 | 주소 키워드로 공식 우체국 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
||||
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
|
||||
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
|
||||
| 쿠팡 상품 검색 | [coupang-mcp](https://github.com/uju777/coupang-mcp) 서버 경유로 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
|
||||
| 중고차 가격 조회 | 주요 렌터카 업체 비교 후 SK렌터카 다이렉트 타고BUY inventory snapshot 기준으로 인수가/월 렌트료 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
|
||||
|
||||
|
||||
## 처음 시작하는 순서
|
||||
|
|
@ -62,6 +65,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
|||
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
|
||||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
|
||||
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
|
||||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||
|
|
@ -72,6 +76,8 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
|||
- [우편번호 검색](docs/features/zipcode-search.md)
|
||||
- [다이소 상품 조회](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` 실행 → 개별 기능 사용" 입니다.
|
||||
|
|
|
|||
136
coupang-product-search/SKILL.md
Normal file
136
coupang-product-search/SKILL.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
name: coupang-product-search
|
||||
description: coupang-mcp 서버를 통해 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 상품 비교, 베스트 상품, 골드박스 특가를 조회한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: retail
|
||||
locale: ko-KR
|
||||
phase: v2
|
||||
---
|
||||
|
||||
# Coupang Product Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
[coupang-mcp](https://github.com/uju777/coupang-mcp) 서버를 경유하여 쿠팡 상품을 검색하고 실시간 가격을 확인한다.
|
||||
|
||||
- 키워드 상품 검색 (로켓배송/일반배송 구분)
|
||||
- 로켓배송 전용 필터 검색
|
||||
- 가격대 범위 검색
|
||||
- 상품 비교표 생성
|
||||
- 카테고리별 베스트 상품
|
||||
- 골드박스 당일 특가
|
||||
- 인기 검색어/계절 상품 추천
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Claude Code
|
||||
→ MCP Streamable HTTP (JSON-RPC)
|
||||
→ HF Space (coupang-mcp 서버)
|
||||
→ Netlify 프록시 (도쿄)
|
||||
→ 다나와 가격 조회 (1차) / 쿠팡 API 폴백
|
||||
```
|
||||
|
||||
- API 키 불필요
|
||||
- 다나와에서 정확한 판매가 우선 조회, 실패 시 쿠팡 API 가격 자동 폴백
|
||||
- 해외 IP 차단 우회를 위해 도쿄 리전 프록시 경유
|
||||
|
||||
## MCP endpoint
|
||||
|
||||
```
|
||||
https://yuju777-coupang-mcp.hf.space/mcp
|
||||
```
|
||||
|
||||
## When to use
|
||||
|
||||
- "쿠팡에서 생수 가격 좀 찾아줘"
|
||||
- "로켓배송 에어팟 찾아줘"
|
||||
- "20만원 이하 키보드 추천해줘"
|
||||
- "아이패드 vs 갤럭시탭 비교"
|
||||
- "오늘 쿠팡 특가 뭐 있어?"
|
||||
- "전자제품 베스트 보여줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 로그인, 장바구니, 결제 자동화가 필요한 경우
|
||||
- 쿠팡 계정/session 접근이 필요한 경우
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Clarify the need
|
||||
|
||||
검색어가 너무 넓으면 먼저 의도를 좁힌다.
|
||||
|
||||
- 권장 질문: `어떤 용도/예산/브랜드/용량을 우선할까요?`
|
||||
|
||||
### 2. Initialize MCP session
|
||||
|
||||
coupang-mcp는 MCP Streamable HTTP 프로토콜을 사용한다. 세션을 초기화한 뒤 도구를 호출한다.
|
||||
|
||||
```bash
|
||||
# Step 1: Initialize and get session ID
|
||||
SESSION_ID=$(curl -s -X POST "https://yuju777-coupang-mcp.hf.space/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"k-skill","version":"1.0"}}}' \
|
||||
-D /dev/stderr 2>&1 1>/dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r')
|
||||
```
|
||||
|
||||
### 3. Call tools
|
||||
|
||||
세션 ID를 얻은 뒤 `tools/call` 로 원하는 도구를 호출한다.
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://yuju777-coupang-mcp.hf.space/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_coupang_products","arguments":{"keyword":"32인치 4K 모니터"}}}' \
|
||||
2>&1 | grep "^data:" | sed 's/^data: //'
|
||||
```
|
||||
|
||||
## Available tools
|
||||
|
||||
| 도구명 | 기능 | 파라미터 예시 |
|
||||
|--------|------|-------------|
|
||||
| `search_coupang_products` | 일반 상품 검색 | `{"keyword":"생수"}` |
|
||||
| `search_coupang_rocket` | 로켓배송만 필터링 | `{"keyword":"에어팟"}` |
|
||||
| `search_coupang_budget` | 가격대 범위 검색 | `{"keyword":"키보드","min_price":0,"max_price":100000}` |
|
||||
| `compare_coupang_products` | 상품 비교표 생성 | `{"keyword":"아이패드 vs 갤럭시탭"}` |
|
||||
| `get_coupang_recommendations` | 인기 검색어 제안 | `{}` |
|
||||
| `get_coupang_seasonal` | 계절/상황별 추천 | `{"keyword":"설날 선물"}` |
|
||||
| `get_coupang_best_products` | 카테고리별 베스트 | `{"keyword":"전자제품"}` |
|
||||
| `get_coupang_goldbox` | 당일 특가 정보 | `{}` |
|
||||
|
||||
## Response format
|
||||
|
||||
결과는 로켓배송(rocket)과 일반배송(normal)으로 구분되어 반환된다.
|
||||
|
||||
```
|
||||
## rocket (6)
|
||||
|
||||
1) LG전자 4K UHD 모니터
|
||||
옵션: 80cm / 32UR500K
|
||||
가격: 397,750원 (39만원대)
|
||||
보러가기: https://link.coupang.com/a/...
|
||||
|
||||
## normal (4)
|
||||
|
||||
1) 삼성전자 QHD 오디세이 G5 게이밍 모니터
|
||||
가격: 283,000원 (28만원대)
|
||||
보러가기: https://link.coupang.com/a/...
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 후보가 여러 개면 상위 3~5개만 짧게 비교한다.
|
||||
- 로켓배송/일반배송 구분을 명시한다.
|
||||
- 가격은 참고용임을 안내한다 (다나와 실패 시 쿠팡 API 추정가).
|
||||
- MCP 서버가 응답하지 않으면 서버 상태를 알리고 나중에 재시도를 권한다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 검색 결과가 로켓배송/일반배송으로 구분되어 정리되었다.
|
||||
- 사용자 니즈에 맞는 추천 TOP 3이 제시되었다.
|
||||
- 가격/배송 정보가 포함되었다.
|
||||
103
docs/features/coupang-product-search.md
Normal file
103
docs/features/coupang-product-search.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# 쿠팡 상품 검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
[coupang-mcp](https://github.com/uju777/coupang-mcp) 서버를 통해 쿠팡 상품을 검색하고 실시간 가격을 확인한다.
|
||||
|
||||
- 키워드 상품 검색 (로켓배송/일반배송 구분)
|
||||
- 로켓배송 전용 필터 검색
|
||||
- 가격대 범위 검색
|
||||
- 상품 비교표 생성
|
||||
- 카테고리별 베스트 상품, 골드박스 당일 특가
|
||||
- 인기 검색어/계절 상품 추천
|
||||
|
||||
## 동작 방식
|
||||
|
||||
```
|
||||
Claude Code → MCP JSON-RPC → HF Space (coupang-mcp) → Netlify 프록시 (도쿄) → 다나와/쿠팡
|
||||
```
|
||||
|
||||
- **API 키 불필요** — coupang-mcp가 다나와 가격 조회를 1차로, 쿠팡 API를 폴백으로 사용
|
||||
- 해외 IP 차단 우회를 위해 도쿄 리전 Netlify 프록시 경유
|
||||
|
||||
## MCP 엔드포인트
|
||||
|
||||
```
|
||||
https://yuju777-coupang-mcp.hf.space/mcp
|
||||
```
|
||||
|
||||
프로토콜: MCP Streamable HTTP (JSON-RPC 2.0)
|
||||
|
||||
## 사용 가능한 도구
|
||||
|
||||
| 도구명 | 기능 | 사용 예시 |
|
||||
|--------|------|----------|
|
||||
| `search_coupang_products` | 일반 상품 검색 | "맥북 검색해줘" |
|
||||
| `search_coupang_rocket` | 로켓배송만 필터링 | "로켓배송 에어팟 찾아줘" |
|
||||
| `search_coupang_budget` | 가격대 범위 검색 | "10만원 이하 키보드" |
|
||||
| `compare_coupang_products` | 상품 비교표 생성 | "아이패드 vs 갤럭시탭" |
|
||||
| `get_coupang_recommendations` | 인기 검색어 제안 | "요즘 뭐가 인기야?" |
|
||||
| `get_coupang_seasonal` | 계절/상황별 추천 | "설날 선물 추천" |
|
||||
| `get_coupang_best_products` | 카테고리별 베스트 | "전자제품 베스트" |
|
||||
| `get_coupang_goldbox` | 당일 특가 정보 | "오늘 특가 뭐있어?" |
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 검색어를 받는다. 너무 넓으면 용도/예산/브랜드를 먼저 물어본다.
|
||||
2. MCP 세션을 초기화한다 (`initialize` → `Mcp-Session-Id` 확보).
|
||||
3. `tools/call`로 적절한 도구를 호출한다.
|
||||
4. 결과를 로켓배송/일반배송으로 구분하여 정리한다.
|
||||
5. 상위 3~5개 추천과 함께 가격/배송 정보를 제공한다.
|
||||
|
||||
## 호출 예시
|
||||
|
||||
```bash
|
||||
# 1. 세션 초기화
|
||||
curl -s -X POST "https://yuju777-coupang-mcp.hf.space/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-03-26",
|
||||
"capabilities":{},
|
||||
"clientInfo":{"name":"k-skill","version":"1.0"}
|
||||
}}'
|
||||
# → 응답 헤더에서 Mcp-Session-Id 확보
|
||||
|
||||
# 2. 상품 검색
|
||||
curl -s -X POST "https://yuju777-coupang-mcp.hf.space/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-H "Mcp-Session-Id: <session-id>" \
|
||||
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
|
||||
"name":"search_coupang_products",
|
||||
"arguments":{"keyword":"생수"}
|
||||
}}'
|
||||
```
|
||||
|
||||
## 결과 형식
|
||||
|
||||
```
|
||||
## rocket (6)
|
||||
|
||||
1) LG전자 4K UHD 모니터
|
||||
옵션: 80cm / 32UR500K
|
||||
가격: 397,750원 (39만원대)
|
||||
보러가기: https://link.coupang.com/a/...
|
||||
|
||||
## normal (4)
|
||||
|
||||
1) 삼성전자 QHD 오디세이 G5 게이밍 모니터
|
||||
가격: 283,000원 (28만원대)
|
||||
보러가기: https://link.coupang.com/a/...
|
||||
```
|
||||
|
||||
## 제한사항
|
||||
|
||||
- 가격은 참고용이다. 다나와 조회 실패 시 쿠팡 API 추정가가 표시된다.
|
||||
- 로그인, 장바구니, 결제 자동화는 지원하지 않는다.
|
||||
- MCP 서버(HF Space)가 다운되면 일시적으로 사용 불가하다.
|
||||
|
||||
## 출처
|
||||
|
||||
- [coupang-mcp GitHub](https://github.com/uju777/coupang-mcp)
|
||||
- MCP 엔드포인트: `https://yuju777-coupang-mcp.hf.space/mcp`
|
||||
|
|
@ -16,17 +16,19 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
|
||||
- `GET /health`
|
||||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
|
||||
|
||||
## 권장 환경변수
|
||||
|
||||
클라이언트(스킬) 쪽:
|
||||
|
||||
- `KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org`
|
||||
- `KSKILL_PROXY_BASE_URL=https://your-proxy.example.com`
|
||||
|
||||
프록시 서버 쪽:
|
||||
|
||||
- `AIR_KOREA_OPEN_API_KEY=...`
|
||||
- `SEOUL_OPEN_API_KEY=...`
|
||||
- `KSKILL_PROXY_PORT=4020`
|
||||
|
||||
## PM2 + cloudflared
|
||||
|
|
@ -54,6 +56,13 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
|||
--data-urlencode 'regionHint=서울 강남구'
|
||||
```
|
||||
|
||||
서울 지하철 도착정보 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
AirKorea passthrough endpoint:
|
||||
|
||||
```bash
|
||||
|
|
@ -70,3 +79,4 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
|
|||
|
||||
- upstream key는 프록시 서버에서만 관리합니다.
|
||||
- client 쪽에는 upstream API key를 배포하지 않습니다.
|
||||
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.
|
||||
|
|
|
|||
127
docs/features/korean-law-search.md
Normal file
127
docs/features/korean-law-search.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# 한국 법령 검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- `korean-law-mcp` 로 법령명 검색
|
||||
- 특정 법령의 조문 본문 조회
|
||||
- 판례 / 유권해석 / 자치법규 검색
|
||||
- MCP 또는 CLI 경로 중 현재 환경에 맞는 방식 선택
|
||||
- 기존 경로 장애 시 `법망` fallback으로 이어가기
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
한국 법령 관련 검색/조회가 필요할 때는 **`korean-law-mcp`를 먼저 사용**합니다.
|
||||
기존 서비스가 동작하지 않을 때만 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 전환합니다.
|
||||
별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- remote MCP endpoint를 쓸 MCP 클라이언트
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
|
||||
무료 API key 발급처: `https://open.law.go.kr`
|
||||
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
```
|
||||
|
||||
로컬 설치가 막히면 먼저 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 사용한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용한다.
|
||||
|
||||
## MCP 연결 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"command": "korean-law-mcp",
|
||||
"env": {
|
||||
"LAW_OC": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
remote endpoint 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"url": "https://korean-law-mcp.fly.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
위 remote 예시는 upstream 문서 기준으로 사용자 `LAW_OC` 를 따로 넣지 않는다. 사용자 쪽에서 준비할 것은 `url` 등록뿐이다.
|
||||
|
||||
## fallback: 법망
|
||||
|
||||
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 `법망`을 사용한다.
|
||||
|
||||
### MCP fallback
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"beopmang": {
|
||||
"url": "https://api.beopmang.org/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### REST fallback 예시
|
||||
|
||||
```bash
|
||||
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
|
||||
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
|
||||
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 질의가 법령/판례/행정해석/자치법규 중 어디에 가까운지 분류한다.
|
||||
2. 법령명만 찾으면 `search_law` 를 먼저 쓴다.
|
||||
3. 특정 조문이 필요하면 `search_law` 또는 `search_all` 로 식별자(`mst`)를 확인한 뒤 `get_law_text` 를 호출한다.
|
||||
4. 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
|
||||
5. 범주가 애매하면 `search_all` 로 시작한다.
|
||||
6. `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 막히면 `법망` fallback으로 전환한다.
|
||||
7. fallback 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
|
||||
|
||||
## CLI 예시
|
||||
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- `화관법` 같은 약칭은 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 번호가 헷갈리면 `get_law_text` 전에 법령 식별자부터 다시 확인한다.
|
||||
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보를 안내한다.
|
||||
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
|
||||
- 기존 `korean-law-mcp` 경로가 실패하면 `https://api.beopmang.org/mcp` 또는 `/api/v4/law?action=search` 경로를 fallback으로 쓴다.
|
||||
- 요약은 할 수 있지만 법률 자문처럼 단정적으로 결론을 내리지는 않는다.
|
||||
|
||||
## 라이브 확인 메모
|
||||
|
||||
2026-04-01 기준 smoke test 에서 아래 명령은 실제로 정상 동작했다.
|
||||
|
||||
- `korean-law list`
|
||||
- `korean-law help search_law`
|
||||
|
||||
즉, `korean-law-mcp` CLI 설치와 기본 명령 진입은 검증했다. 실제 법령 검색은 로컬 CLI/MCP 경로라면 `LAW_OC` 가 준비된 환경에서 바로 이어서 사용할 수 있고, remote MCP endpoint는 사용자 `LAW_OC` 없이 URL 등록만으로 붙일 수 있다. 기존 경로 장애 시에는 `법망` fallback을 사용할 수 있다.
|
||||
|
|
@ -5,23 +5,25 @@
|
|||
- 역 기준 실시간 도착 예정 열차 조회
|
||||
- 상/하행 또는 외/내선 정보 확인
|
||||
- 첫 번째/두 번째 도착 메시지 확인
|
||||
- 개인 OpenAPI key 없이 `k-skill-proxy` 경유 조회
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 완료
|
||||
- [보안/시크릿 정책](../security-and-secrets.md) 확인
|
||||
- 서울 열린데이터 광장 API key
|
||||
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
## 필요한 환경변수
|
||||
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
|
||||
|
||||
### Credential resolution order
|
||||
사용자가 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요는 없다. 대신 `KSKILL_PROXY_BASE_URL` 은 `/v1/seoul-subway/arrival` route가 실제로 배포된 proxy를 가리켜야 한다. upstream key는 proxy 서버에서만 관리한다.
|
||||
|
||||
1. **이미 환경변수에 있으면** 그대로 사용한다.
|
||||
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
|
||||
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
|
||||
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
|
||||
### Proxy resolution order
|
||||
|
||||
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용합니다.
|
||||
2. **없으면** 사용자/운영자에게 self-host 또는 배포 확인이 끝난 proxy URL 을 먼저 확보합니다.
|
||||
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key를 서버 쪽에만 설정합니다.
|
||||
|
||||
## 입력값
|
||||
|
||||
|
|
@ -30,14 +32,24 @@
|
|||
|
||||
## 기본 흐름
|
||||
|
||||
1. `SEOUL_OPEN_API_KEY` 가 없으면 credential resolution order에 따라 확보합니다.
|
||||
3. 역명 기준으로 실시간 도착정보를 조회합니다.
|
||||
4. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
|
||||
1. `KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인합니다.
|
||||
2. `/v1/seoul-subway/arrival?stationName=...` 로 역명 기준 실시간 도착정보를 조회합니다.
|
||||
3. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
|
||||
curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
범위를 줄이거나 늘리고 싶으면:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=서울역' \
|
||||
--data-urlencode 'startIndex=0' \
|
||||
--data-urlencode 'endIndex=4'
|
||||
```
|
||||
|
||||
## 주의할 점
|
||||
|
|
@ -45,3 +57,5 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
|
|||
- 실시간 데이터라 몇 초 단위로 바뀔 수 있습니다.
|
||||
- 역명 표기가 다르면 결과가 비어 있을 수 있습니다.
|
||||
- 일일 호출 제한이나 quota 초과 가능성이 있습니다.
|
||||
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시합니다.
|
||||
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 봅니다.
|
||||
|
|
|
|||
86
docs/features/used-car-price-search.md
Normal file
86
docs/features/used-car-price-search.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# 중고차 가격 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 주요 한국 렌터카 업체를 먼저 비교한 뒤 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-02T07:52:24Z 기준 `https://www.skdirect.co.kr/tb` 에 대해 live smoke run 을 다시 수행했고, inventory 규모는 시점에 따라 변동될 수 있었지만 `캐스퍼`, `K3`, `티볼리`, `아반떼`, `쏘나타`, `투싼`, `싼타페`, `QM6`, `그랜저`, `스포티지` 순으로 **10회** 차종 조회를 반복해도 구조화된 결과를 계속 얻을 수 있었다.
|
||||
|
|
@ -50,12 +50,15 @@ npx --yes skills add <owner/repo> \
|
|||
--skill toss-securities \
|
||||
--skill lotto-results \
|
||||
--skill kakaotalk-mac \
|
||||
--skill korean-law-search \
|
||||
--skill fine-dust-location \
|
||||
--skill daiso-product-search \
|
||||
--skill blue-ribbon-nearby \
|
||||
--skill kakao-bar-nearby \
|
||||
--skill zipcode-search \
|
||||
--skill delivery-tracking
|
||||
--skill delivery-tracking \
|
||||
--skill coupang-product-search \
|
||||
--skill used-car-price-search
|
||||
```
|
||||
|
||||
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
|
||||
|
|
@ -65,10 +68,25 @@ npx --yes skills add <owner/repo> \
|
|||
--skill k-skill-setup \
|
||||
--skill srt-booking \
|
||||
--skill ktx-booking \
|
||||
--skill korean-law-search \
|
||||
--skill seoul-subway-arrival \
|
||||
--skill fine-dust-location
|
||||
```
|
||||
|
||||
`korean-law-search` 는 skill 설치 후 upstream CLI/MCP도 준비해야 한다.
|
||||
|
||||
- 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운다.
|
||||
- remote endpoint는 `LAW_OC` 없이 `url`만 등록한다.
|
||||
- 기존 `korean-law-mcp` 경로가 실패하면 `법망`(`https://api.beopmang.org`) fallback을 사용한다.
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
korean-law list
|
||||
```
|
||||
|
||||
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
|
||||
|
||||
로컬 저장소에서 바로 전체 설치 테스트:
|
||||
|
||||
```bash
|
||||
|
|
@ -103,7 +121,7 @@ npm run ci
|
|||
### Node 패키지
|
||||
|
||||
```bash
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
|
|
@ -141,6 +159,7 @@ python3 -m pip install SRTrain korail2 pycryptodome
|
|||
- `ktx-booking`
|
||||
- `seoul-subway-arrival`
|
||||
- `fine-dust-location`
|
||||
- `korean-law-search`
|
||||
|
||||
관련 문서:
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@
|
|||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 한국 법령 검색 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
LAW_OC=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
```
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지만 hosted proxy 로 쓸 때는 이 값을 비워 두고 skill 기본값을 써도 된다.
|
||||
|
||||
## Missing secret handling policy
|
||||
|
||||
인증이 필요한 스킬에서 필요한 값이 없으면 우회하지 않는다.
|
||||
|
|
@ -58,8 +60,10 @@ KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
|||
- `KSKILL_SRT_PASSWORD`
|
||||
- `KSKILL_KTX_ID`
|
||||
- `KSKILL_KTX_PASSWORD`
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `LAW_OC`
|
||||
- `AIR_KOREA_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 공통 설정 가이드
|
||||
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 서울 지하철 도착정보 조회, 미세먼지 조회)을 사용하려면 이 절차를 진행하면 된다.
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
|
|
@ -24,15 +24,21 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
LAW_OC=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
EOF
|
||||
chmod 0600 ~/.config/k-skill/secrets.env
|
||||
```
|
||||
|
||||
실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지만 쓴다면 이 값을 비워 두고 skill 기본 hosted path를 그대로 써도 된다.
|
||||
|
||||
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC` 는 `korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp` 와 `korean-law list` 로 설치 상태를 확인한다.
|
||||
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
|
||||
|
||||
## 확인
|
||||
|
||||
```bash
|
||||
|
|
@ -52,7 +58,9 @@ bash scripts/check-setup.sh
|
|||
| --- | --- |
|
||||
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
|
||||
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
|
||||
| 서울 지하철 도착정보 조회 | `SEOUL_OPEN_API_KEY` |
|
||||
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
|
||||
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
|
||||
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
|
||||
## 다음에 볼 문서
|
||||
|
|
@ -61,6 +69,7 @@ bash scripts/check-setup.sh
|
|||
- [KTX 예매 가이드](features/ktx-booking.md)
|
||||
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
|
||||
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
|
||||
- [한국 법령 검색 가이드](features/korean-law-search.md)
|
||||
- [보안/시크릿 정책](security-and-secrets.md)
|
||||
|
||||
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
|
||||
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
|
||||
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
|
||||
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
|
||||
- beopmang: https://api.beopmang.org
|
||||
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
|
||||
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
|
||||
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
|
||||
|
|
@ -24,6 +26,8 @@
|
|||
- 다이소몰 상품 요약 목록: 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
|
||||
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
|
||||
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
|
||||
- 블루리본 메인: https://www.bluer.co.kr/
|
||||
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
|
||||
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
|
||||
|
|
@ -37,3 +41,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/
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
LAW_OC=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
|
|
|
|||
|
|
@ -67,14 +67,19 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
LAW_OC=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
|
||||
EOF
|
||||
chmod 0600 ~/.config/k-skill/secrets.env
|
||||
```
|
||||
|
||||
유저에게 물어서 실제 값을 채운다.
|
||||
|
||||
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지만 hosted proxy 로 쓸 때는 이 값을 비워 둘 수 있다.
|
||||
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
|
||||
|
||||
### Missing secret response template
|
||||
|
||||
인증 스킬에서 값이 빠졌을 때는 credential resolution order에 따라 확보한다.
|
||||
|
|
@ -83,8 +88,10 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
|
||||
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
|
||||
- 서울 지하철: `SEOUL_OPEN_API_KEY`
|
||||
- 사용자 위치 미세먼지 조회: `AIR_KOREA_OPEN_API_KEY`
|
||||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
|
||||
|
||||
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.
|
||||
|
||||
|
|
|
|||
174
korean-law-search/SKILL.md
Normal file
174
korean-law-search/SKILL.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
---
|
||||
name: korean-law-search
|
||||
description: Use korean-law-mcp first for Korean law lookups, and fall back to Beopmang when the primary service is unavailable.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: legal
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Korean Law Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
한국 법령/조문/판례/유권해석/자치법규 조회가 필요할 때 기본 경로로 **`korean-law-mcp`를 먼저 사용**하고, 기존 서비스가 동작하지 않을 때는 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 이어간다.
|
||||
|
||||
- 법령명 검색: `search_law`
|
||||
- 조문 본문 조회: `get_law_text`
|
||||
- 판례 검색: `search_precedents`
|
||||
- 유권해석 검색: `search_interpretations`
|
||||
- 자치법규 검색: `search_ordinance`
|
||||
- 여러 카테고리가 섞인 검색: `search_all`
|
||||
|
||||
이 스킬은 자체 npm/python 패키지를 만들지 않는다. 한국 법령 관련 조회는 기본적으로 `korean-law-mcp` 로 처리하고, 해당 경로가 막히거나 실패가 반복될 때만 승인된 fallback 표면인 `법망`을 사용한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "산업안전보건법 찾아줘"
|
||||
- "관세법 제38조 보여줘"
|
||||
- "부당해고 판례 찾아줘"
|
||||
- "개인정보보호법 시행령 조문 확인해줘"
|
||||
- "한국 법령/판례/자치법규 검색해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 미국/일본/EU 등 비한국 법령 검색
|
||||
- 실제 법률 자문·소송 전략을 단정적으로 제공해야 하는 경우
|
||||
- 법령 원문이 아니라 일반 상식 설명만 필요한 경우
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- MCP 클라이언트에 remote endpoint를 등록할 수 있는 환경
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
|
||||
무료 API key: `https://open.law.go.kr`
|
||||
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
```
|
||||
|
||||
로컬 설치가 운영체제 정책이나 권한 때문에 막히면 먼저 `korean-law-mcp` 의 remote MCP endpoint(`https://korean-law-mcp.fly.dev/mcp`)를 사용한다. 그래도 기존 경로가 응답하지 않거나 서비스 장애로 조회가 막히면, 승인된 fallback 표면인 `법망` MCP/REST(`https://api.beopmang.org`)로 전환한다.
|
||||
|
||||
## MCP client setup
|
||||
|
||||
Claude Desktop / Cursor / Windsurf 같은 MCP 클라이언트에는 아래처럼 연결한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"command": "korean-law-mcp",
|
||||
"env": {
|
||||
"LAW_OC": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
설치가 막힌 환경에서는 remote endpoint를 사용한다. 이 upstream 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"url": "https://korean-law-mcp.fly.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback workflow (`법망`)
|
||||
|
||||
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 아래 fallback을 사용한다.
|
||||
|
||||
### 1. MCP fallback
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"beopmang": {
|
||||
"url": "https://api.beopmang.org/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. REST fallback
|
||||
|
||||
```bash
|
||||
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
|
||||
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
|
||||
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
|
||||
```
|
||||
|
||||
## CLI workflow
|
||||
|
||||
### 1. 법령명부터 찾기
|
||||
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
```
|
||||
|
||||
### 2. 특정 조문 본문 조회
|
||||
|
||||
```bash
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
```
|
||||
|
||||
### 3. 판례 검색
|
||||
|
||||
```bash
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
|
||||
### 4. 자치법규 검색
|
||||
|
||||
```bash
|
||||
korean-law search_ordinance --query "서울특별시 청년 기본 조례"
|
||||
```
|
||||
|
||||
### 5. 애매하면 통합 검색
|
||||
|
||||
```bash
|
||||
korean-law search_all --query "개인정보 처리방침 행정해석"
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 한국 법령 관련 요청은 **항상 `korean-law-mcp`를 먼저 사용**한다.
|
||||
- 기존 `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 실패하면 `법망`(`https://api.beopmang.org`)을 fallback으로 사용한다.
|
||||
- 약칭(`화관법`)이면 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`mst`)를 확인한 뒤 `get_law_text` 로 본문을 가져온다.
|
||||
- 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
|
||||
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보 방법을 짧게 안내하고, 임의의 크롤링/검색엔진 우회로 넘어가지 않는다.
|
||||
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
|
||||
- 법적 판단이 필요한 경우 `검색 결과 요약`과 `원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 한국 법령 관련 질의에 대해 `korean-law-mcp` 사용 경로가 선택되었다.
|
||||
- 필요한 검색/조회 명령이 정해졌다.
|
||||
- 법령/조문/판례/유권해석/자치법규 중 맞는 도구로 결과를 조회했다.
|
||||
- 유권해석이면 `search_interpretations`, 자치법규면 `search_ordinance` 까지 명시적으로 연결했다.
|
||||
- 로컬 경로라면 `LAW_OC` 확보 방법을 정확한 변수 이름으로 안내했다.
|
||||
- remote endpoint라면 사용자 `LAW_OC` 없이 `url` 등록 상태를 확인했다.
|
||||
- 기존 경로 장애 시 `법망` fallback(MCP 또는 REST)으로 이어지는 안내가 포함되었다.
|
||||
|
||||
## Notes
|
||||
|
||||
- upstream: `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- fallback surface: `https://api.beopmang.org`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`)
|
||||
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -585,6 +585,10 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/coupang-product-search": {
|
||||
"resolved": "packages/coupang-product-search",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"dev": true,
|
||||
|
|
@ -1593,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,
|
||||
|
|
@ -1614,6 +1622,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/coupang-product-search": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/daiso-product-search": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
|
|
@ -1658,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 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 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"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
# k-skill-proxy
|
||||
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. 지금은 AirKorea 미세먼지 조회를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회와 서울 지하철 실시간 도착정보를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
|
||||
## 현재 제공 엔드포인트
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
|
||||
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
|
|
@ -26,6 +28,13 @@ node packages/k-skill-proxy/src/server.js
|
|||
|
||||
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
|
||||
|
||||
서울 지하철 도착정보 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
## PM2 실행
|
||||
|
||||
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 환경변수로 다시 올라옵니다.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
const crypto = require("node:crypto");
|
||||
const Fastify = require("fastify");
|
||||
const { fetchFineDustReport } = require("./airkorea");
|
||||
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
|
||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||
|
|
@ -40,6 +41,7 @@ function buildConfig(env = process.env) {
|
|||
port: parseInteger(env.KSKILL_PROXY_PORT, 4020),
|
||||
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
|
||||
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
|
||||
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -120,6 +122,26 @@ function normalizeFineDustQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeSeoulSubwayQuery(query) {
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
|
||||
if (!stationName) {
|
||||
throw new Error("Provide stationName.");
|
||||
}
|
||||
|
||||
const startIndex = parseInteger(query.startIndex ?? query.start_index, 0);
|
||||
const endIndex = parseInteger(query.endIndex ?? query.end_index, 8);
|
||||
|
||||
if (startIndex < 0 || endIndex < startIndex) {
|
||||
throw new Error("Provide valid startIndex and endIndex.");
|
||||
}
|
||||
|
||||
return {
|
||||
stationName,
|
||||
startIndex,
|
||||
endIndex
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedAirKoreaRoute(service, operation) {
|
||||
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
|
||||
}
|
||||
|
|
@ -147,7 +169,7 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
|
|||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
|
||||
const url = new URL(`${AIR_KOREA_UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
|
||||
for (const [key, value] of Object.entries(query || {})) {
|
||||
if (value === undefined || value === null || value === "" || key === "serviceKey") {
|
||||
continue;
|
||||
|
|
@ -172,6 +194,40 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
|
|||
};
|
||||
}
|
||||
|
||||
async function proxySeoulSubwayRequest({
|
||||
stationName,
|
||||
startIndex = 0,
|
||||
endIndex = 8,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: "upstream_not_configured",
|
||||
message: "SEOUL_OPEN_API_KEY is not configured on the proxy server."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const encodedStationName = encodeURIComponent(stationName);
|
||||
const url = new URL(
|
||||
`${SEOUL_OPEN_API_BASE_URL}/api/subway/${apiKey}/json/realtimeStationArrival/${startIndex}/${endIndex}/${encodedStationName}`
|
||||
);
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
|
||||
body: await response.text()
|
||||
};
|
||||
}
|
||||
|
||||
function buildServer({ env = process.env, provider = null } = {}) {
|
||||
const config = buildConfig(env);
|
||||
const cache = createMemoryCache();
|
||||
|
|
@ -202,7 +258,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
service: config.proxyName,
|
||||
port: config.port,
|
||||
upstreams: {
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey)
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey),
|
||||
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -284,6 +341,66 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/seoul-subway/arrival", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeSeoulSubwayQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "seoul-subway-arrival",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxySeoulSubwayRequest({
|
||||
...normalized,
|
||||
apiKey: config.seoulOpenApiKey
|
||||
});
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
||||
if (!upstream.contentType.includes("json")) {
|
||||
return upstream.body;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(upstream.body);
|
||||
payload.proxy = {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
request.log.error(error);
|
||||
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
|
|
@ -324,6 +441,8 @@ module.exports = {
|
|||
buildConfig,
|
||||
buildServer,
|
||||
normalizeFineDustQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
proxyAirKoreaRequest,
|
||||
proxySeoulSubwayRequest,
|
||||
startServer
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { buildServer, proxyAirKoreaRequest } = require("../src/server");
|
||||
const { buildServer, proxyAirKoreaRequest, proxySeoulSubwayRequest } = require("../src/server");
|
||||
|
||||
test("health endpoint stays public and reports auth/upstream status", async (t) => {
|
||||
const app = buildServer({
|
||||
|
|
@ -24,6 +24,7 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
|
|||
assert.equal(body.ok, true);
|
||||
assert.equal(body.auth.tokenRequired, false);
|
||||
assert.equal(body.upstreams.airKoreaConfigured, false);
|
||||
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
|
||||
});
|
||||
|
||||
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
|
|
@ -176,3 +177,148 @@ test("public AirKorea passthrough route forwards allowed upstream responses", as
|
|||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.body, /resultCode/);
|
||||
});
|
||||
|
||||
test("seoul subway endpoint caches successful upstream responses for normalized queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
errorMessage: {
|
||||
status: 200,
|
||||
code: "INFO-000",
|
||||
message: "정상 처리되었습니다."
|
||||
},
|
||||
realtimeArrivalList: [
|
||||
{
|
||||
statnNm: "강남",
|
||||
trainLineNm: "2호선",
|
||||
updnLine: "내선",
|
||||
arvlMsg2: "전역 출발",
|
||||
arvlMsg3: "역삼",
|
||||
barvlDt: "60"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
SEOUL_OPEN_API_KEY: "seoul-key",
|
||||
KSKILL_PROXY_CACHE_TTL_MS: "60000"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const first = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?station=%EA%B0%95%EB%82%A8&start_index=0&end_index=8"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(fetchCalls, 1);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
});
|
||||
|
||||
test("seoul subway endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl;
|
||||
global.fetch = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
errorMessage: {
|
||||
status: 200,
|
||||
code: "INFO-000",
|
||||
message: "정상 처리되었습니다."
|
||||
},
|
||||
realtimeArrivalList: [
|
||||
{
|
||||
statnNm: "강남",
|
||||
trainLineNm: "2호선",
|
||||
updnLine: "내선",
|
||||
arvlMsg2: "전역 출발",
|
||||
arvlMsg3: "역삼",
|
||||
barvlDt: "60"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
SEOUL_OPEN_API_KEY: "seoul-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().realtimeArrivalList[0].statnNm, "강남");
|
||||
assert.match(calledUrl, /realtimeStationArrival\/0\/8\/%EA%B0%95%EB%82%A8$/);
|
||||
});
|
||||
|
||||
test("seoul subway endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("proxySeoulSubwayRequest injects API key and preserves index/station params", async () => {
|
||||
let calledUrl;
|
||||
const result = await proxySeoulSubwayRequest({
|
||||
stationName: "강남",
|
||||
startIndex: "2",
|
||||
endIndex: "5",
|
||||
apiKey: "test-seoul-key",
|
||||
fetchImpl: async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
|
||||
});
|
||||
|
|
|
|||
50
packages/used-car-price-search/README.md
Normal file
50
packages/used-car-price-search/README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# used-car-price-search
|
||||
|
||||
한국 중고차(렌터카 출신) 가격/인수가를 조회하는 Node.js helper 입니다. 공급자(provider) 구조로 되어 있어 새 업체를 추가할 수 있습니다.
|
||||
|
||||
## 현재 지원 공급자
|
||||
|
||||
| id | 업체 | 출처 |
|
||||
| --- | --- | --- |
|
||||
| `sk-tagobuy` | SK렌터카 다이렉트 타고BUY | `https://www.skdirect.co.kr/tb` (`__NEXT_DATA__`) |
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
const { lookupPrices, fetchInventory, providers } = require("used-car-price-search")
|
||||
|
||||
// 키워드 검색 (기본 공급자: sk-tagobuy)
|
||||
const result = await lookupPrices("아반떼", { limit: 5 })
|
||||
|
||||
// 특정 공급자 지정
|
||||
const result = await lookupPrices("아반떼", { provider: "sk-tagobuy", limit: 5 })
|
||||
|
||||
// 전체 inventory 조회
|
||||
const inventory = await fetchInventory()
|
||||
|
||||
// 등록된 공급자 목록
|
||||
console.log(Object.keys(providers))
|
||||
```
|
||||
|
||||
## 공급자 추가 방법
|
||||
|
||||
`src/providers/` 아래에 아래 인터페이스를 따르는 모듈을 만들고 `src/index.js`의 `providers` 레지스트리에 등록하면 됩니다.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
provider: { id, name, siteUrl, ... },
|
||||
fetchInventory(options) → { provider, total, items, fetchedAt }
|
||||
}
|
||||
```
|
||||
|
||||
`items`의 각 항목은 공통 필드(`maker`, `model`, `displayName`, `monthlyPrice`, `buyoutPrice`, `mileageKm`, `fuel`, `searchText` 등)를 포함해야 합니다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 공개 HTML 안의 데이터를 읽는 방식이라 별도 로그인이나 비공개 API key 가 필요하지 않습니다.
|
||||
- 결과는 `월 렌트료`와 `인수가`를 함께 노출합니다.
|
||||
- 검색은 현재 inventory snapshot 기준 키워드 매칭입니다.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
이 패키지는 각 공급자의 공개 데이터를 조회합니다. 어떤 업체와도 제휴·광고·후원 관계가 없으며, 공식 제품이 아닙니다. 광고 및 제휴 제안은 언제든 환영합니다.
|
||||
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/search.js && node --check src/util.js && node --check src/providers/sk-tagobuy.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
51
packages/used-car-price-search/src/index.js
Normal file
51
packages/used-car-price-search/src/index.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
const skTagoBuy = require("./providers/sk-tagobuy")
|
||||
const { filterCarsByQuery, summarizeMatches } = require("./search")
|
||||
|
||||
const providers = {
|
||||
"sk-tagobuy": skTagoBuy
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER = "sk-tagobuy"
|
||||
|
||||
function resolveProvider(options = {}) {
|
||||
const id = options.provider || DEFAULT_PROVIDER
|
||||
const resolved = providers[id]
|
||||
|
||||
if (!resolved) {
|
||||
const available = Object.keys(providers).join(", ")
|
||||
throw new Error(`Unknown provider "${id}". Available: ${available}`)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fetchInventory(options = {}) {
|
||||
const providerModule = resolveProvider(options)
|
||||
return providerModule.fetchInventory(options)
|
||||
}
|
||||
|
||||
async function lookupPrices(query, options = {}) {
|
||||
const limit = Number(options.limit || 10)
|
||||
const inventory = await fetchInventory(options)
|
||||
const allMatches = filterCarsByQuery(inventory.items, query)
|
||||
const matches = allMatches.slice(0, limit)
|
||||
|
||||
return {
|
||||
provider: inventory.provider,
|
||||
fetchedAt: inventory.fetchedAt,
|
||||
query: String(query || "").trim(),
|
||||
totalInventory: inventory.total,
|
||||
matchedCount: allMatches.length,
|
||||
summary: summarizeMatches(allMatches),
|
||||
items: matches
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
providers,
|
||||
fetchInventory,
|
||||
lookupPrices,
|
||||
// back-compat aliases
|
||||
fetchUsedCarInventory: fetchInventory,
|
||||
lookupUsedCarPrices: lookupPrices
|
||||
}
|
||||
123
packages/used-car-price-search/src/providers/sk-tagobuy.js
Normal file
123
packages/used-car-price-search/src/providers/sk-tagobuy.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
const { cleanText, toNumber, toManwonRounded, toYearMonth, uniqueJoin } = require("../util")
|
||||
|
||||
const SK_TAGOBUY_URL = "https://www.skdirect.co.kr/tb"
|
||||
const NEXT_DATA_PATTERN = /<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
const provider = {
|
||||
id: "sk-tagobuy",
|
||||
name: "SK렌터카 다이렉트 타고BUY",
|
||||
siteUrl: SK_TAGOBUY_URL,
|
||||
inventoryPath: "/tb",
|
||||
extraction: "next-data"
|
||||
}
|
||||
|
||||
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 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 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 parseInventory(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, total: items.length, items }
|
||||
}
|
||||
|
||||
async function fetchInventory(options = {}) {
|
||||
const fetchImpl = options.fetchImpl || global.fetch
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.")
|
||||
}
|
||||
|
||||
const response = await fetchImpl(options.url || SK_TAGOBUY_URL, {
|
||||
headers: {
|
||||
...DEFAULT_BROWSER_HEADERS,
|
||||
...(options.headers || {})
|
||||
},
|
||||
signal: options.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SK direct request failed with ${response.status} for ${options.url || SK_TAGOBUY_URL}`)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
const inventory = parseInventory(html)
|
||||
|
||||
return {
|
||||
...inventory,
|
||||
fetchedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
provider,
|
||||
extractNextData,
|
||||
parseInventory,
|
||||
fetchInventory
|
||||
}
|
||||
87
packages/used-car-price-search/src/search.js
Normal file
87
packages/used-car-price-search/src/search.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
const { cleanText, normalizeSearchKey } = require("./util")
|
||||
|
||||
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 compareBuyout(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 compareBuyout(left, right) {
|
||||
return Number(left.buyoutPrice || 0) - Number(right.buyoutPrice || 0)
|
||||
}
|
||||
|
||||
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 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)))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
filterCarsByQuery,
|
||||
summarizeMatches
|
||||
}
|
||||
41
packages/used-car-price-search/src/util.js
Normal file
41
packages/used-car-price-search/src/util.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
function cleanText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function normalizeSearchKey(value) {
|
||||
return cleanText(value)
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "")
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const amount = Number(String(value ?? "").replace(/,/g, ""))
|
||||
return Number.isFinite(amount) ? amount : 0
|
||||
}
|
||||
|
||||
function toManwonRounded(value) {
|
||||
const amount = toNumber(value)
|
||||
return amount ? Math.round(amount / 10000) : 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 uniqueJoin(parts) {
|
||||
return [...new Set(parts.map(cleanText).filter(Boolean))].join(" ")
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanText,
|
||||
normalizeSearchKey,
|
||||
toNumber,
|
||||
toManwonRounded,
|
||||
toYearMonth,
|
||||
uniqueJoin
|
||||
}
|
||||
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>
|
||||
175
packages/used-car-price-search/test/index.test.js
Normal file
175
packages/used-car-price-search/test/index.test.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
const test = require("node:test")
|
||||
const assert = require("node:assert/strict")
|
||||
const fs = require("node:fs")
|
||||
const path = require("node:path")
|
||||
|
||||
const {
|
||||
providers,
|
||||
fetchInventory,
|
||||
lookupPrices
|
||||
} = require("../src/index")
|
||||
const { extractNextData, parseInventory } = require("../src/providers/sk-tagobuy")
|
||||
const { summarizeMatches } = require("../src/search")
|
||||
|
||||
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("sk-tagobuy parseInventory exposes public used-car price fields", () => {
|
||||
const inventory = parseInventory(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 = parseInventory(inventoryHtml)
|
||||
const summary = summarizeMatches(inventory.items)
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
count: 3,
|
||||
monthlyPriceMin: 350100,
|
||||
monthlyPriceMax: 392100,
|
||||
buyoutPriceMin: 10466815,
|
||||
buyoutPriceMax: 12900000,
|
||||
mileageKmMin: 61931,
|
||||
mileageKmMax: 100570
|
||||
})
|
||||
})
|
||||
|
||||
test("lookupPrices 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 lookupPrices("현대 아반떼", { 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("lookupPrices 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 lookupPrices("K3", { limit: 5 })
|
||||
assert.equal(k3.matchedCount, 1)
|
||||
assert.equal(k3.items[0].maker, "기아")
|
||||
|
||||
const nothing = await lookupPrices("쏘렌토", { limit: 5 })
|
||||
assert.equal(nothing.matchedCount, 0)
|
||||
assert.deepEqual(nothing.items, [])
|
||||
assert.equal(nothing.summary, null)
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("lookupPrices reports summary and matchedCount from all matches before applying the item limit", async () => {
|
||||
const originalFetch = global.fetch
|
||||
global.fetch = async () => makeHtmlResponse(inventoryHtml)
|
||||
|
||||
try {
|
||||
const result = await lookupPrices("현대", { limit: 1 })
|
||||
|
||||
assert.equal(result.matchedCount, 2)
|
||||
assert.equal(result.items.length, 1)
|
||||
assert.equal(result.items[0].model, "캐스퍼")
|
||||
assert.deepEqual(result.summary, {
|
||||
count: 2,
|
||||
monthlyPriceMin: 350100,
|
||||
monthlyPriceMax: 392100,
|
||||
buyoutPriceMin: 10466815,
|
||||
buyoutPriceMax: 12900000,
|
||||
mileageKmMin: 61931,
|
||||
mileageKmMax: 64581
|
||||
})
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("fetchInventory 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 fetchInventory()
|
||||
|
||||
assert.equal(requestedUrl, "https://www.skdirect.co.kr/tb")
|
||||
assert.equal(inventory.total, 0)
|
||||
assert.deepEqual(inventory.items, [])
|
||||
} finally {
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("providers registry exposes sk-tagobuy by default", () => {
|
||||
assert.ok(providers["sk-tagobuy"])
|
||||
assert.equal(providers["sk-tagobuy"].provider.name, "SK렌터카 다이렉트 타고BUY")
|
||||
})
|
||||
|
||||
test("fetchInventory rejects an unknown provider", async () => {
|
||||
await assert.rejects(
|
||||
() => fetchInventory({ provider: "nonexistent" }),
|
||||
(err) => {
|
||||
assert.match(err.message, /Unknown provider/)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function makeHtmlResponse(body) {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -101,6 +101,15 @@ test("root npm test script includes the skill docs regression suite", () => {
|
|||
assert.match(packageJson.scripts.test, /node --test scripts\/skill-docs\.test\.js/);
|
||||
});
|
||||
|
||||
test("README advertises OpenClaw among the supported coding agents", () => {
|
||||
const readme = read("README.md");
|
||||
|
||||
assert.match(
|
||||
readme,
|
||||
/Claude Code, Codex, OpenCode, OpenClaw\/ClawHub 등 각종 코딩 에이전트 지원합니다\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("hwp skill documents environment-aware routing and supported operations", () => {
|
||||
const skillPath = path.join(repoRoot, "hwp", "SKILL.md");
|
||||
|
||||
|
|
@ -157,6 +166,90 @@ 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/);
|
||||
assert.match(
|
||||
install,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
|
||||
);
|
||||
});
|
||||
|
||||
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(featureDoc, /2026-04-02/);
|
||||
assert.match(featureDoc, /inventory 규모는 시점에 따라 변동될 수/);
|
||||
assert.doesNotMatch(featureDoc, /총 `\d+대`/);
|
||||
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"));
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const skill = read(path.join("seoul-subway-arrival", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "seoul-subway-arrival.md"));
|
||||
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
||||
|
||||
assert.match(readme, /\| 서울 지하철 도착정보 조회 \| .* \| 프록시 URL 필요 \|/);
|
||||
assert.match(setup, /\| 서울 지하철 도착정보 조회 \| self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` \|/);
|
||||
assert.match(install, /--skill seoul-subway-arrival/);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(doc, /\/v1\/seoul-subway\/arrival/);
|
||||
assert.match(doc, /사용자가 .*OpenAPI key.*직접.*필요(가|는)? 없다|개인 API key 없이/i);
|
||||
assert.match(doc, /self-host|운영 중인 proxy|배포가 끝난 proxy/i);
|
||||
assert.doesNotMatch(doc, /SEOUL_OPEN_API_KEY/);
|
||||
assert.doesNotMatch(doc, /swopenAPI\.seoul\.go\.kr\/api\/subway\/\$\{SEOUL_OPEN_API_KEY\}/);
|
||||
assert.doesNotMatch(doc, /기본값 `https:\/\/k-skill-proxy\.nomadamas\.org`/);
|
||||
assert.doesNotMatch(doc, /없으면 hosted proxy .*기본/);
|
||||
}
|
||||
|
||||
assert.match(proxyDoc, /GET \/v1\/seoul-subway\/arrival/);
|
||||
assert.match(proxyDoc, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(proxyReadme, /GET \/v1\/seoul-subway\/arrival/);
|
||||
assert.match(proxyReadme, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(security, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(security, /배포가 끝난 proxy|self-host/i);
|
||||
assert.match(setupSkill, /서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`/);
|
||||
assert.doesNotMatch(secretsExample, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/your-proxy\.example\.com/);
|
||||
assert.doesNotMatch(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
|
||||
});
|
||||
|
||||
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
||||
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
||||
|
||||
|
|
@ -556,6 +649,33 @@ 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 coupang-product-search skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "coupang-product-search.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/coupang-product-search.md to exist");
|
||||
assert.match(readme, /\| 쿠팡 상품 검색 \|/);
|
||||
assert.match(readme, /\[쿠팡 상품 검색 가이드\]\(docs\/features\/coupang-product-search\.md\)/);
|
||||
assert.match(install, /--skill coupang-product-search/);
|
||||
});
|
||||
|
||||
test("coupang-product-search skill and docs reference coupang-mcp", () => {
|
||||
const skillPath = path.join(repoRoot, "coupang-product-search", "SKILL.md");
|
||||
const featureDoc = read(path.join("docs", "features", "coupang-product-search.md"));
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected coupang-product-search/SKILL.md to exist");
|
||||
|
||||
const skill = read(path.join("coupang-product-search", "SKILL.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /coupang-mcp/);
|
||||
assert.match(doc, /yuju777-coupang-mcp\.hf\.space\/mcp/);
|
||||
assert.match(doc, /search_coupang_products/);
|
||||
assert.match(doc, /로켓배송/);
|
||||
}
|
||||
});
|
||||
|
||||
test("root pack:dry-run script covers all publishable workspaces", () => {
|
||||
const packageJson = readJson("package.json");
|
||||
|
||||
|
|
@ -875,6 +995,20 @@ 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("used-car-price-search ships with a changeset for release automation", () => {
|
||||
const changesetDir = path.join(repoRoot, ".changeset");
|
||||
const changesetFiles = fs
|
||||
.readdirSync(changesetDir)
|
||||
.filter((name) => name.endsWith(".md"))
|
||||
.map((name) => read(path.join(".changeset", name)));
|
||||
|
||||
assert.ok(
|
||||
changesetFiles.some((doc) => /["']used-car-price-search["']:\s*(patch|minor|major)/.test(doc)),
|
||||
"expected a changeset entry that releases used-car-price-search",
|
||||
);
|
||||
});
|
||||
|
||||
test("package-lock captures the toss-securities workspace metadata for npm ci", () => {
|
||||
|
|
@ -889,3 +1023,95 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
|
|||
assert.equal(packageLock.packages["packages/toss-securities"].license, "MIT");
|
||||
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
|
||||
});
|
||||
|
||||
test("repository docs advertise the korean-law-search skill with mode-specific korean-law-mcp setup guidance", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-law-search.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-law-search.md to exist");
|
||||
assert.match(readme, /\| 한국 법령 검색 \|/);
|
||||
assert.match(readme, /\[한국 법령 검색 가이드\]\(docs\/features\/korean-law-search\.md\)/);
|
||||
assert.match(readme, /로컬 CLI\/MCP면 `LAW_OC` 필요, remote endpoint\/법망 fallback은 불필요/);
|
||||
assert.match(readme, /법망 fallback/i);
|
||||
assert.match(install, /--skill korean-law-search/);
|
||||
assert.match(install, /로컬 CLI\/MCP 경로는 `LAW_OC`/);
|
||||
assert.match(install, /remote endpoint는 `LAW_OC` 없이 `url`만/);
|
||||
assert.match(setup, /한국 법령 검색의 로컬 CLI\/MCP 경로용 `LAW_OC`/);
|
||||
assert.match(setup, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(featureDoc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(featureDoc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(setupSkill, /로컬 한국 법령 검색: `LAW_OC` \+ `korean-law-mcp`/);
|
||||
assert.match(setupSkill, /remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록/);
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /LAW_OC/);
|
||||
assert.match(doc, /korean-law-mcp/);
|
||||
}
|
||||
|
||||
assert.match(sources, /korean-law-mcp: https:\/\/github\.com\/chrisryugj\/korean-law-mcp/);
|
||||
assert.match(sources, /beopmang: https:\/\/api\.beopmang\.org/);
|
||||
assert.match(roadmap, /한국 법령 검색 스킬 출시/);
|
||||
});
|
||||
|
||||
test("korean-law-search skill keeps korean-law-mcp-first guidance while documenting the approved Beopmang fallback", () => {
|
||||
const skillPath = path.join(repoRoot, "korean-law-search", "SKILL.md");
|
||||
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
||||
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
||||
const packageJson = readJson("package.json");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected korean-law-search/SKILL.md to exist");
|
||||
|
||||
const skill = read(path.join("korean-law-search", "SKILL.md"));
|
||||
const doneSectionMatch = skill.match(/## Done when([\s\S]*?)## Notes/);
|
||||
|
||||
assert.match(skill, /^name: korean-law-search$/m);
|
||||
assert.ok(doneSectionMatch, "expected korean-law-search skill to include a Done when section");
|
||||
|
||||
const doneSection = doneSectionMatch[1];
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /korean-law-mcp.*먼저|먼저.*korean-law-mcp|항상 `korean-law-mcp`를 먼저 사용/u);
|
||||
assert.match(doc, /npm install -g korean-law-mcp/);
|
||||
assert.match(doc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(doc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(doc, /open\.law\.go\.kr/);
|
||||
assert.match(doc, /search_law/);
|
||||
assert.match(doc, /get_law_text/);
|
||||
assert.match(doc, /search_precedents/);
|
||||
assert.match(doc, /search_interpretations/);
|
||||
assert.match(doc, /search_ordinance/);
|
||||
assert.match(doc, /https:\/\/korean-law-mcp\.fly\.dev\/mcp/);
|
||||
assert.match(doc, /법망|Beopmang/i);
|
||||
assert.match(doc, /https:\/\/api\.beopmang\.org/);
|
||||
assert.match(doc, /fallback/i);
|
||||
assert.match(doc, /MCP/i);
|
||||
assert.match(doc, /CLI/i);
|
||||
assert.doesNotMatch(doc, /packages\/korean-law-search/);
|
||||
assert.doesNotMatch(doc, /python-packages\/korean-law-search/);
|
||||
}
|
||||
|
||||
assert.match(doneSection, /search_interpretations/);
|
||||
assert.match(doneSection, /search_ordinance/);
|
||||
assert.match(doneSection, /법망|Beopmang/i);
|
||||
assert.match(doneSection, /fallback/i);
|
||||
|
||||
assert.doesNotMatch(
|
||||
featureDoc,
|
||||
/[ \t]+$/m,
|
||||
"expected docs/features/korean-law-search.md to avoid trailing whitespace so git diff --check stays clean",
|
||||
);
|
||||
|
||||
assert.match(examplesSecrets, /^LAW_OC=replace-me$/m);
|
||||
assert.ok(
|
||||
!packageJson.workspaces.some((workspace) => workspace.includes("korean-law")),
|
||||
"expected no repo workspace to be added for korean-law-search",
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-law-search")), false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API로 역 기준 도착 예정 열차 정보를 요약한다.
|
||||
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API를 `k-skill-proxy` 경유로 조회해 역 기준 도착 예정 열차 정보를 요약한다.
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -22,21 +22,22 @@ metadata:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- 서울 열린데이터 광장 API key
|
||||
- optional: `jq`
|
||||
- self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
## Required environment variables
|
||||
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
|
||||
|
||||
### Credential resolution order
|
||||
사용자가 개인 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요는 없다. 대신 `/v1/seoul-subway/arrival` route가 실제로 올라와 있는 proxy URL 을 `KSKILL_PROXY_BASE_URL` 로 받아야 한다. upstream key는 proxy 서버 쪽에만 보관한다.
|
||||
|
||||
1. **이미 환경변수에 있으면** 그대로 사용한다.
|
||||
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
|
||||
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
|
||||
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
|
||||
### Proxy resolution order
|
||||
|
||||
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
|
||||
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용한다.
|
||||
2. **없으면** 사용자/운영자에게 self-host 또는 배포 확인이 끝난 proxy URL 을 먼저 확보한다.
|
||||
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key를 서버 쪽에만 설정한다.
|
||||
|
||||
클라이언트/사용자 쪽에서 upstream key를 직접 다루지 않는다.
|
||||
|
||||
## Inputs
|
||||
|
||||
|
|
@ -45,20 +46,21 @@ metadata:
|
|||
|
||||
## Workflow
|
||||
|
||||
### 1. Ensure credentials are available
|
||||
### 1. Resolve the proxy base URL
|
||||
|
||||
`SEOUL_OPEN_API_KEY` 환경변수가 설정되어 있는지 확인한다. 없으면 위 credential resolution order에 따라 확보한다.
|
||||
|
||||
시크릿이 없다는 이유로 비공식 미러 API나 다른 출처로 자동 우회하지 않는다.
|
||||
`KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
|
||||
|
||||
### 2. Query the official station arrival endpoint
|
||||
|
||||
서울 실시간 지하철 API는 역명 기준 실시간 도착 정보를 JSON/XML로 제공한다. 기본 질의 예시는 다음 패턴을 쓴다.
|
||||
proxy는 서울 실시간 지하철 API key를 서버에서 주입하고, 역명 기준 실시간 도착정보만 공개 read-only endpoint로 노출한다.
|
||||
|
||||
```bash
|
||||
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
|
||||
curl -fsS --get 'https://your-proxy.example.com/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
필요하면 `startIndex`, `endIndex` 로 응답 범위를 조정할 수 있다.
|
||||
|
||||
### 3. Summarize the response
|
||||
|
||||
가능하면 아래 항목만 먼저 요약한다.
|
||||
|
|
@ -77,15 +79,17 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
|
|||
|
||||
- 요청 역의 도착 예정 열차가 정리되어 있다
|
||||
- live data 기준 시점이 명시되어 있다
|
||||
- key가 노출되지 않았다
|
||||
- upstream key가 클라이언트에 노출되지 않았다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- API key 미설정
|
||||
- proxy upstream key 미설정
|
||||
- quota 초과
|
||||
- 역명 표기 불일치
|
||||
- public hosted route rollout 전인데 `KSKILL_PROXY_BASE_URL` 을 비워 둔 경우
|
||||
|
||||
## Notes
|
||||
|
||||
- 서울 열린데이터 광장 가이드는 실시간 지하철 Open API에 일일 호출 제한이 있을 수 있다고 안내한다
|
||||
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다
|
||||
- endpoint path는 API 버전 변경 가능성이 있으므로 실패 시 dataset console의 최신 샘플 URL을 다시 확인한다
|
||||
|
|
|
|||
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