Sync dev → main: MFDS proxy fixes, cache hardening, HWP kordoc, KRX degraded handling + new skills (#152)

* Add a guided Hola Poke Yeoksam skill without widening repo scope

Issue #120 only needs a repository skill payload, discoverability docs,
and regression coverage. This change adds the new skill, wires it into
existing docs surfaces, and locks the remote-MCP-only contract in tests
so future edits keep the phone-only event flow and verbatim message
relay behavior.

Constraint: The upstream Hola Poke flow lives on a remote MCP server, so this repo should not add proxy/runtime code
Constraint: Tests must be written before refining the new docs/skill wording
Rejected: Add local package or proxy support for Hola Poke | would over-scope a docs-only skill addition
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this skill limited to 올라포케 역삼점 and treat the MCP response message as the event source of truth
Tested: node --test scripts/skill-docs.test.js --test-name-pattern='hola-poke-yeoksam'
Tested: npm run ci
Tested: Live MCP initialize/tools/list/get_menu/get_shop_info/enter_event(phone_format) smoke checks against https://hola-poke-yeoksam-skill.onrender.com/mcp
Not-tested: Successful live event entry with a real phone number

* Help users find nearby public restrooms from Korean location queries

This adds a new public-restroom-nearby skill and reusable package that resolves a user-provided location, narrows the official 공중화장실정보 dataset by region when possible, and ranks nearby restroom results with opening-time hints and map links.

Constraint: Must use free official/open surfaces without introducing new dependencies
Constraint: Must follow TDD and keep release/docs metadata aligned in the same change
Rejected: Add a proxy route first | direct official CSV access already works and keeps scope narrower
Rejected: Use nationwide-only ranking without regional narrowing | too much noisy data for dense urban anchors
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If Kakao place-panel or localdata CSV schema changes, update parser fixtures before broad logic changes
Tested: npm run ci; live smoke via searchNearbyPublicRestroomsByLocationQuery('광화문', { limit: 3 }); architect review APPROVED
Not-tested: Non-Seoul live smoke across every regional orgCode

* Pin the Hola Poke MCP contract in repo-owned regression fixtures

The earlier issue #120 regression only matched prose, so this follow-up records the verified remote MCP tool/result snapshot in a checked-in fixture and makes both docs surfaces byte-align to it. That keeps the discoverability docs honest while turning the review claim into a real contract lock for tools/list, get_menu, get_shop_info, and the invalid-phone event flow.

Constraint: The upstream remote MCP server can change independently of this repo
Rejected: Keep prose-only regex checks | would not catch contract drift
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Refresh the fixture, both JSON fences, and the live-smoke evidence together whenever the upstream contract changes
Tested: node --test scripts/skill-docs.test.js --test-name-pattern='hola-poke-yeoksam'; npm run ci; live MCP smoke check against https://hola-poke-yeoksam-skill.onrender.com/mcp (initialize, tools/list, get_menu, get_shop_info, invalid enter_event)
Not-tested: Successful enter_event with a real phone number (intentionally avoided to prevent live event participation)

* Keep nearby restroom lookups resilient to flaky Kakao place panels

The review caught two regressions in the new public-restroom-nearby package: a single broken Kakao panel aborted anchor resolution, and coordinate search dropped maxDistanceMeters before normalization. This change adds targeted regression coverage first, keeps per-candidate HTTP failures recoverable, and hardens request errors with explicit status/url metadata so fallback logic no longer depends on parsing error strings.

Constraint: Must preserve the published package surface and keep the fix scoped to PR #123 follow-up
Rejected: Swallow all panel errors | would hide non-HTTP failures like network faults
Rejected: Parse request error messages for status codes | brittle coupling to string formatting
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep recoverable Kakao panel handling aligned with request() error annotations if request() changes again
Tested: npm test --workspace public-restroom-nearby
Tested: npm run ci
Tested: live smoke searchNearbyPublicRestroomsByLocationQuery('광화문', { limit: 3 })
Tested: LSP diagnostics on packages/public-restroom-nearby/src/index.js and test/index.test.js
Not-tested: Live Kakao fallback against a real upstream 5xx place-panel response

* Keep the Hola Poke contract claims aligned with verified coverage

The reviewed fixture-based regression already locks the documented remote
snapshot, but the docs still implied the enter_event success path had
live proof. Narrow the docs and the regression so they explicitly say the
success fields are pinned by the recorded snapshot while the live smoke
only verifies the invalid-phone retry path.

Constraint: Live success-path verification would trigger a real event entry and is intentionally avoided
Rejected: Leave the broader wording in place | review feedback showed it overstated the live evidence
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If a safe non-mutating success-path probe becomes available, update the docs and fixture wording together
Tested: node --test scripts/skill-docs.test.js --test-name-pattern='hola-poke-yeoksam'; npm run ci; live MCP smoke against https://hola-poke-yeoksam-skill.onrender.com/mcp (initialize, tools/list, get_menu subset, get_shop_info subset, invalid enter_event)
Not-tested: Real enter_event success-path invocation

* Document the restroom distance-cap contract with regression coverage

The approved issue-117 code fix already restored maxDistanceMeters behavior, but the published docs did not lock or explain that contract. This follow-up adds a failing-first doc regression, then updates the feature guide and package README with the verified 100m example so users and future reviewers see the same behavior the package now ships.

Constraint: Must stay scoped to the existing PR #123 follow-up without reopening the implementation surface
Rejected: Leave the behavior implicit in code/tests only | published docs would lag the verified contract
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the public-restroom-nearby docs and skill-docs regression aligned with live maxDistanceMeters smoke evidence if the sample query changes
Tested: node --test scripts/skill-docs.test.js (red then green)
Tested: npm test --workspace public-restroom-nearby
Tested: npm run ci
Tested: live smoke searchNearbyPublicRestroomsByLocationQuery('광화문', { limit: 3 })
Tested: live smoke searchNearbyPublicRestroomsByLocationQuery('광화문', { limit: 3, maxDistanceMeters: 100 })
Tested: architect review APPROVED
Not-tested: Alternative landmark queries with a non-zero maxDistanceMeters hit set

* Expose KRX partial failures instead of misreporting stock lookups

The Korean stock proxy used to silently drop failed market snapshots during
search and could turn an empty holiday trade snapshot into a 502 by falling
back into base-info lookup.

This change surfaces degraded market metadata on partial search success,
short-circuits empty trade snapshots to not_found, and refreshes the user
docs to use a real trading day in examples.

Constraint: KOSPI base-info approval is granted separately from other KRX routes
Constraint: Healthy markets should still return usable search results during a partial outage
Rejected: Return 502 on every partial search failure | hides still-usable markets and breaks current clients unnecessarily
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep degraded search metadata when any market snapshot fetch fails so partial outages stay visible
Tested: npm test --workspace k-skill-proxy
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Not-tested: Live KOSPI base-info behavior after the new KRX permission is approved

* Adopt kordoc for the hwp skill workflow

Issue #119 replaces the previous HWP guidance with kordoc so the skill matches the newer agent-native document flow. The docs and regression tests now center the HWP skill on kordoc parsing, JSON extraction, diffing, form filling, and Markdown-to-HWPX round-tripping, while the install/source references stay in sync.

Constraint: The repository treats skill behavior as documentation contracts backed by regression tests
Constraint: The requested branch/PR flow must target dev with TDD and verified execution evidence
Rejected: Keep @ohah/hwpjs or hwp-mcp as fallback guidance | issue #119 explicitly approves replacing the prior stack with kordoc
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future hwp skill/docs/tests aligned to a single kordoc-first contract unless a new issue explicitly reintroduces multi-backend routing
Tested: node --test scripts/skill-docs.test.js; npm run ci; temp-dir kordoc roundtrip via markdownToHwpx -> sample.hwpx -> kordoc CLI markdown output; architect review APPROVED
Not-tested: Live parsing of user-provided proprietary HWP/HWPX samples outside the generated roundtrip fixture

* Prevent degraded stock search outages from sticking in cache

Reviewer feedback showed that partial KRX market failures could be cached as full search answers, masking recovery on the next identical request. This change adds a regression that fails first, skips route-level caching for degraded search payloads, and keeps the trade-info empty-snapshot contract documented alongside the partial-failure response semantics.

Constraint: Existing PR #124 already targets dev and must remain the follow-up lane for issue #99
Constraint: Proxy behavior must stay read-only and dependency-free
Rejected: Cache degraded search payloads for a short TTL | still risks transient false negatives during the TTL window
Rejected: Broaden trade-info fallback behavior | empty snapshots should stay explicit not_found results
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep degraded search responses out of the long-lived route cache unless a future design adds explicit revalidation semantics
Tested: npm test --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; npm run ci; explicit buildServer degraded-search recovery repro
Not-tested: Live KRX production endpoints from this branch

* Align HWP docs with the published kordoc surface

The issue #119 follow-up needs the repository contract to match what the
currently published kordoc package actually supports. This narrows the
HWP skill/docs/tests to the verified install requirement and supported
CLI/Node API surfaces, and removes unsupported fill/mcp claims.

Constraint: Published kordoc CLI fails at startup without pdfjs-dist
Constraint: Docs/tests must reflect the current npm package behavior, not intended future features
Rejected: Keep fill/mcp examples with caveats | still documents unsupported entrypoints
Confidence: high
Scope-risk: narrow
Directive: Reintroduce fill/mcp docs only after verifying the published package exposes them in both CLI and Node API
Tested: node --test scripts/skill-docs.test.js; npm run ci; temp-dir clean install smoke; temp-dir kordoc+pdfjs-dist watch/parse/extractFormFields/compare/markdownToHwpx/roundtrip smoke; Claude architect review
Not-tested: Real-world HWPX template that produces non-empty extractFormFields output

* Keep HWP docs runnable against the published kordoc package

The follow-up closes the last runnable-contract gaps from review by documenting the working one-shot npx form and separating Node API examples into a local project install path. The regression suite now locks both install notes so future edits do not drift back to broken command shapes.

Constraint: Published kordoc CLI still requires pdfjs-dist at startup
Constraint: Global NODE_PATH does not make ESM imports from kordoc resolvable in the documented examples
Rejected: Keep bare `npx kordoc` examples | fails in a clean environment
Rejected: Keep global-install Node API guidance | ESM import remains unresolved
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep HWP docs aligned to verified published kordoc surfaces until the package contract changes upstream
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: temp-dir local npm install kordoc pdfjs-dist plus markdownToHwpx -> sample.hwpx -> one-shot kordoc roundtrip smoke
Not-tested: upstream unpublished kordoc features beyond the verified CLI and Node API surfaces

* Add Korean scholarship search skill and reporting workflow (#116)

* Add nationwide scholarship search skill workflow

* Rename scholarship skill to 장학금 주세요 쮜에발

* Fix scholarship skill validation in CI

* Trigger GitHub PR diff refresh after dev rebase on main

* Fix scholarship helper status handling and test coverage

* Use KST as scholarship helper default date basis

* Rename scholarship skill display name

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>

* Feature/#121 (#127)

* Recover KakaoTalk mac skill auth when upstream user_id detection fails

Issue #121 reproduces on a real MacBook because `kakaocli auth` can fail even when the encrypted hex-named DB exists. This change adds a thin repo-owned helper that recovers the active user_id from plist revision hashes, caches the validated DB/key tuple, and reuses it for read-only `kakaocli` commands. The skill and feature docs now steer users to the helper when upstream auto-detection stops at candidate key mismatch, and regression tests lock the recovery flow before the implementation.

Constraint: Must stay a thin adapter around upstream kakaocli rather than forking the CLI
Constraint: Must verify on a real local macOS KakaoTalk install where issue #121 reproduces
Rejected: Full kakaocli reimplementation inside k-skill | too broad for the user_id/key-derivation failure scope
Rejected: Docs-only workaround | does not actually fix the broken auth path for users
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep this helper limited to auth/key recovery and read-only passthrough unless upstream gaps widen materially
Tested: python3 -m unittest scripts.test_kakaotalk_mac
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000
Tested: python3 scripts/kakaotalk_mac.py chats --limit 1 --json
Not-tested: Other kakaocli subcommands beyond auth/chats/messages/search/query/schema

* Protect the KakaoTalk helper's safe recovery path

Address the PR follow-up by treating malformed auth cache files as cache misses,
removing write-capable passthrough from the wrapper surface, and redacting
human-readable auth output so the cached SQLCipher key is not echoed back into
terminal history. The docs and regression suite now describe and enforce the
read-only contract that the helper is meant to preserve.

Constraint: Helper must remain a read-only recovery wrapper around local kakaocli access
Rejected: Keep query support with SQL validation | still leaves a risky write-capable escape hatch
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not re-expose arbitrary SQL passthrough or print the SQLCipher key in default text output
Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>; python3 scripts/kakaotalk_mac.py query --help
Not-tested: External automation consumers that depend on shell/json auth output beyond the documented helper flows

* Lock the helper CLI surface against accidental regressions

The approved issue #121 fixes already hardened the KakaoTalk Mac helper, but the test suite still only exercised the passthrough validator directly. Add an explicit parser-level regression so the public CLI contract stays read-only and `query` cannot quietly reappear in future edits.

Constraint: Follow-up is on the existing feature/#121 PR branch and must stay minimal
Rejected: Re-open helper implementation changes | current code already satisfies the approved review findings
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep parser exposure tests aligned with READ_ONLY_COMMANDS whenever helper subcommands change
Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>
Not-tested: No new production code paths changed in this follow-up

* Honor explicit Kakao auth recovery overrides

The helper now treats manual auth overrides as a cache-bypassing recovery request and rejects invalid brute-force tuning flags at the CLI boundary so users get deterministic behavior instead of stale cached tuples or Python tracebacks. Regression coverage locks both paths before the PR follow-up lands.

Constraint: The helper must remain a thin read-only wrapper around kakaocli auth recovery
Rejected: Require --refresh whenever --user-id/--uuid is passed | worse UX than honoring overrides directly
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep explicit auth overrides ahead of cache reuse unless the CLI contract is redesigned and documented
Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id -1; python3 scripts/kakaotalk_mac.py auth --refresh --workers 2 --chunk-size 0 --max-user-id 10; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --user-id 999; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --uuid <live-uuid>
Not-tested: Manual override success with a truly alternate valid user_id/uuid pair on a multi-account local install

* Feature/#129 (#131)

* Add official KBL results support so basketball queries use live league data

Issue #129 needs a read-only skill and reusable package for KBL schedules, results, and standings. The implementation follows the existing sports package pattern and uses the league's live JSON APIs after verifying they respond successfully in real requests.

Constraint: Must use official KBL JSON surfaces before considering scraping
Constraint: Packaging changes must pass npm run ci and include docs plus Changesets updates
Rejected: Browser scraping first | official api.kbl.or.kr endpoints are live and simpler to maintain
Rejected: Reuse KBO/K League package shapes verbatim | KBL payload and team/status fields differ materially
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep seasonGrade=1 as the default KBL path unless future docs/tests explicitly widen to D-League flows
Tested: npm run ci; npm run lint --workspace kbl-results; npm test --workspace kbl-results; live getKBLSummary("2026-04-01", { team: "KCC", includeStandings: true })
Not-tested: Historical standings snapshots for past seasons via alternative KBL endpoints

* Prevent optional standings lookups from over-fetching the KBL API

The new kbl-results summary helper exposes includeStandings=false, so the
regression suite now proves that path stays schedule-only and never calls
the standings endpoint when the caller opts out.

Constraint: The KBL package should preserve the caller's no-standings contract
Rejected: Rely on manual inspection of the helper options | a targeted test is cheaper and safer
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep includeStandings=false side-effect free unless the public API contract changes explicitly
Tested: npm test --workspace kbl-results; npm run lint --workspace kbl-results
Not-tested: Full-repo CI before stacking this commit onto the rebased branch

* Add Naver Shopping price comparison skill

* Use Naver Shopping BFF fallback

* Fix naver shopping BFF page and sort fallback

* Clarify Naver OpenAPI review sort fallback

* Add library book search skill

* Add Data4Library route regression coverage

* Fix Data4Library book-exists ISBN-10 handling

* Refactor Coupang skill to retention MCP layer

* Add Coupang MCP wrapper follow-up coverage

* Clarify Coupang wrapper init guidance

* Document Coupang MCP init examples

* Add parking lot search skill

* Add korean-privacy-terms skill regression tests

* Add korean-privacy-terms thin-wrapper skill

* Document korean-privacy-terms skill across repo docs

* Bundle Apache-2.0 LICENSE with korean-privacy-terms wrapper

Addresses PR #149 review SHOULD FIX: ship the Apache-2.0 LICENSE text
alongside the thin wrapper so Apache License 2.0 §4(a) ('give any other
recipients of the Work or Derivative Works a copy of this License') is
satisfied even before `install.sh` fetches the upstream payload.

- Copy upstream LICENSE verbatim to `korean-privacy-terms/LICENSE.upstream`
  (byte-for-byte identical to upstream at pinned SHA
  e390f7b9feb825e368c26726363ea5ce11a34083; SHA256
  35ef947614c2f14df01c5fc553f987f644f0c9f6b011adda397bd788a87f1510).
- Update SKILL.md Notes to link LICENSE.upstream, clarify that repo-root
  LICENSE (MIT) is k-skill's own license not this skill's, and document
  that nested upstream SKILL.md is not discovered by agent platforms.
- Document the home-path `bash ~/.claude/skills/.../install.sh` variant in
  SKILL.md so users who pulled the wrapper via `npx skills add --skill` can
  install without a repo checkout (installer already resolves
  ${BASH_SOURCE[0]} absolutely).
- Update docs/features/korean-privacy-terms.md to document LICENSE.upstream
  and the §4(a) rationale.
- Strengthen skill-docs regression tests (NICE TO HAVE items from review):
  * Reject placeholder pins (all-zero / all-f 40-char strings).
  * Assert the literal upstream clone URL
    (https://github.com/kimlawtech/korean-privacy-terms.git).
  * Assert `git clone --filter=blob:none` is used for blobless fetches.
  * Add new regression test that verifies LICENSE.upstream exists, matches
    the Apache-2.0 preamble / §4 / APPENDIX structure, and is referenced
    from both SKILL.md and the feature doc.

* Assert APPENDIX anchor in korean-privacy-terms LICENSE.upstream

Close Round 3 NICE TO HAVE from PR #149. The LICENSE.upstream
regression block asserted preamble, Version 2.0, Redistribution,
END OF TERMS, and Copyright 2026 kimlawtech but not the APPENDIX
anchor at LICENSE.upstream:179, even though the Round 1 follow-up
and Round 2 review collectively described 'APPENDIX structure
verification'. Adding this one assertion closes that claim/test
parity gap and acts as tamper-detection if upstream reformats
LICENSE later.

Verified with TDD: temporarily stripped APPENDIX line from
LICENSE.upstream, confirmed test 108 FAILS with the expected
regex mismatch, then restored and re-confirmed 109/109 GREEN.
Byte-for-byte identity with upstream LICENSE still holds
(SHA256 35ef947614c2f14df01c5fc553f987f644f0c9f6b011adda397bd788a87f1510).

npm run ci exit 0 with 357 ok subtests (unchanged baseline,
additive assertion within existing test block).

* Fix extractDataGoItems to handle current data.go.kr JSON shapes

The MFDS data.go.kr drug and food endpoints now return body.items as a
flat array (DrbEasyDrugInfoService, SafeStadDrugService) or an array of
{item: {...}} wrappers (PrsecImproptFoodInfoService03), instead of the
legacy {items: {item: [...]}} XML→JSON auto-convert shape.

Our extractDataGoItems was still looking for body.items.item, so it
returned [] for every entry, silently breaking:
  - /v1/mfds/drug-safety/lookup
  - /v1/mfds/food-safety/search (improperFood portion)

Update extractDataGoItems to accept all three shapes and refresh the
mock fixtures in server.test.js to match what upstream actually returns,
while adding a backward-compat test for the legacy shape.

Note: this does not resolve the remaining FOODSAFETYKOREA_API_KEY being
rejected by upstream (issue #148 core symptom) - that is a separate
operational key rotation on the proxy server.

* Make proxy cache failure-aware and require route-prefixed cache keys

Two related issues surfaced while investigating issue #148:

1. Transient upstream failures were being cached for the full 5-minute
   TTL because every route handler called cache.set() unconditionally
   with whatever payload came back - including empty items + warnings
   from a flaky upstream like openapi.foodsafetykorea.go.kr. The user
   would then see "empty + warning" for 5 minutes even after upstream
   recovered.

2. makeCacheKey(payload) hashes the whole payload, but fine-dust/report
   was the only route calling it without a "route" prefix
   (makeCacheKey(normalized) instead of
   makeCacheKey({ route: "fine-dust-report", ...normalized })).
   Different routes with the same normalized shape could collide.

Fix both globally in the cache layer so every current and future route
benefits without per-route edits:

- createMemoryCache.set rejects any payload that isFailureResponse
  considers a failure (explicit error field, upstream.degraded flag,
  or empty items alongside warnings). Returns false on reject, true
  on accept, so callers can observe the decision if needed.
- makeCacheKey now throws if payload.route is missing or empty. This
  catches the fine-dust inconsistency and prevents new routes from
  reintroducing it.
- fine-dust/report now passes `route: "fine-dust-report"` like every
  other route.

New tests:
- makeCacheKey asserts distinct routes produce distinct keys and throws
  without a route.
- isFailureResponse covers all failure signatures plus graceful-
  fallback cases (items present alongside warnings) that must stay
  cacheable.
- createMemoryCache.set refuses each failure shape and still stores
  healthy payloads.
- End-to-end: food-safety/search with a flaky recall upstream serves
  the upstream failure, retries live when upstream recovers, and only
  caches once the payload is healthy.

TTL itself is unchanged - the value still protects upstream rate
limits; it just no longer amplifies transient errors.

* Document Coupang hosted fallback contract and affiliate disclosure

retention-corp/coupang_partners#1 is merged, so upstream now transparently falls back to the Retention Corp hosted backend at https://a.retn.kr/v1/public/assist when Coupang Partners API credentials are missing. The k-skill wrapper already passes environment variables through unchanged, so this commit lines up the documented contract with the actual two-path behavior without changing runtime logic.

- SKILL.md and docs/features/coupang-product-search.md describe both execution paths (operator local HMAC vs credentialless hosted fallback), the honored OPENCLAW_SHOPPING_* env vars, the allowlist client-id convention including the k-skill-specific coupang-mcp-fallback value, and the mandatory affiliate disclosure when a.retn.kr/s/ shortlinks or lptag=AF deeplinks appear in responses.
- docs/sources.md adds the hosted assist endpoint and the merged upstream PR so the source surface stays truthful.
- README.md reflects the 선택사항 semantics for the 쿠팡 상품 검색 row and extends the column legend so 선택사항 is distinct from 불필요.
- coupang_partners_mcp.py expands its --help epilog so operators discover the honored upstream env vars without reading the wrapper source; no runtime behavior change.
- scripts/test_coupang_partners_mcp_wrapper.py locks env pass-through as a regression, asserts the new --help contract, and adds an opt-in K_SKILL_COUPANG_SMOKE=1 live smoke test that verifies the credentialless hosted path returns a Coupang deeplink.
- scripts/skill-docs.test.js extends the docs regression to require the hosted assist URL, OPENCLAW_SHOPPING_* env prefix, affiliate disclosure wording, and hosted fallback concept while keeping the yuju777 HF Space negative assertion.

Verified: npm run ci exits 0, live smoke test (K_SKILL_COUPANG_SMOKE=1) returns a.retn.kr/s/ shortlinks via credentialless wrapper, and manual env -u COUPANG_ACCESS_KEY -u COUPANG_SECRET_KEY call returns isRocket+lptag=AF3727577 responses through the hosted fallback.

Refs: #134

* Drop non-allowlisted coupang-mcp-fallback recommendation from hosted fallback docs

Direct probes against https://a.retn.kr/v1/public/assist confirmed that
X-OpenClaw-Client-Id: coupang-mcp-fallback returns HTTP 403 Client is not
allowlisted, while the upstream default openclaw-skill returns HTTP 200.
The default wrapper path already works because upstream falls back to
openclaw-skill, but the explicit recommendation in SKILL.md and the
feature doc was luring users to a 403 path.

Remove the dead recommendation and lock in the working configuration:

- Docs describe openclaw-skill as the upstream-allowlisted default and
  note that k-skill does not override OPENCLAW_SHOPPING_CLIENT_ID.
- Wrapper --help epilog drops the Suggested k-skill value line and
  documents openclaw-skill as the allowlist value in play.
- New skill-docs regression asserts coupang-mcp-fallback is absent from
  SKILL.md, the feature doc, the wrapper, and docs/sources.md while
  openclaw-skill is documented across all three narrative surfaces.
- New Python wrapper regression asserts --help drops the dead value and
  surfaces openclaw-skill so the constraint stays locked.
- Existing env-forwarding test uses openclaw-skill as the pass-through
  sentinel so the repo no longer ships the non-allowlisted string at all.

---------

Co-authored-by: minsing-jin <ironman0722@naver.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-21 09:53:03 +09:00 committed by GitHub
commit c002561f34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 6658 additions and 151 deletions

View file

@ -0,0 +1,5 @@
---
"parking-lot-search": patch
---
Add the initial official Data.go.kr based public parking lot search package and skill.

View file

@ -16,7 +16,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
## 어떤 걸 할 수 있나
"사용자 로그인" 컬럼은 **사용자 본인이 직접 로그인/시크릿을 들고 있어야 하는지** 만 표시합니다. `k-skill-proxy` 등 운영자가 관리하는 키는 사용자 입장에서는 **불필요**로 분류합니다.
"사용자 로그인" 컬럼은 **사용자 본인이 직접 로그인/시크릿을 들고 있어야 하는지** 만 표시합니다. `k-skill-proxy` 등 운영자가 관리하는 키는 사용자 입장에서는 **불필요**로 분류합니다. **선택사항**은 사용자가 운영자 키를 직접 들고 있으면 더 풍부한 경로가 켜지고, 없으면 기본 경로(보통 운영자가 관리하는 hosted fallback)로 그대로 동작하는 경우를 말합니다.
| 할 수 있는 일 | 설명 | 사용자 로그인 | 문서 |
| --- | --- | --- | --- |
@ -30,10 +30,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 사용자 위치 미세먼지 조회 | 현재 위치 또는 지역 기준 PM10/PM2.5 미세먼지 조회 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
| 도서관 도서 조회 | 도서관 정보나루 기반 도서 검색, 상세, 소장 도서관, 도서관별 소장 여부 조회 | 불필요 | [도서관 도서 조회 가이드](docs/features/library-book-search.md) |
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
| 식품 안전 체크 | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
@ -41,6 +43,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| 근처 공중화장실 찾기 | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
| 근처 공영주차장 찾기 | 현재 위치 기준 근처 공영주차장 위치·요금·운영시간 조회 | 불필요 | [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md) |
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| KBL 경기 결과 조회 | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
@ -57,11 +60,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 올라포케 역삼 포케 | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
| 택배 배송조회 | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 선택사항 (운영 키 있으면 로컬 HMAC 경로, 없으면 hosted fallback) | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 번개장터 검색 | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
| 중고차 가격 조회 | 중고차 인수가/월 렌트료 비교 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
| 한국어 맞춤법 검사 | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
| 네이버 블로그 리서치 | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
| 네이버 쇼핑 가격비교 | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
| 한국어 글자 수 세기 | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
@ -104,10 +108,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [도서관 도서 조회 가이드](docs/features/library-book-search.md)
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](docs/features/mfds-food-safety.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
@ -115,6 +121,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
@ -136,6 +143,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [릴리스/배포 가이드](docs/releasing.md)

View file

@ -1,6 +1,6 @@
---
name: coupang-product-search
description: coupang-mcp 서버를 통해 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 상품 비교, 베스트 상품, 골드박스 특가를 조회한다.
description: retention-corp/coupang_partners의 로컬 Coupang MCP 호환 레이어로 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 상품 비교, 베스트 상품, 골드박스 특가를 조회한다.
license: MIT
metadata:
category: retail
@ -12,9 +12,9 @@ metadata:
## What this skill does
[coupang-mcp](https://github.com/uju777/coupang-mcp) 서버를 경유하여 쿠팡 상품을 검색하고 실시간 가격을 확인한다.
[retention-corp/coupang_partners](https://github.com/retention-corp/coupang_partners) 저장소의 로컬 Coupang MCP 호환 레이어를 사용해 쿠팡 상품 조회 도구를 실행한다. 기존 유지보수형 HF Space MCP 엔드포인트 대신, 이 저장소의 `bin/coupang_mcp.py`가 제공하는 `local://coupang-mcp` 계약을 호출한다.
- 키워드 상품 검색 (로켓배송/일반배송 구분)
- 키워드 상품 검색
- 로켓배송 전용 필터 검색
- 가격대 범위 검색
- 상품 비교표 생성
@ -25,23 +25,48 @@ metadata:
## How it works
```
Claude Code
→ MCP Streamable HTTP (JSON-RPC)
→ HF Space (coupang-mcp 서버)
→ Netlify 프록시 (도쿄)
→ 다나와 가격 조회 (1차) / 쿠팡 API 폴백
Claude Code / Codex
→ coupang-product-search/scripts/coupang_partners_mcp.py
→ git clone/update retention-corp/coupang_partners (user cache)
→ python3 bin/coupang_mcp.py
→ local://coupang-mcp compatible tool layer
├─ Coupang Partners API client (operator keys present)
└─ hosted fallback → https://a.retn.kr/v1/public/assist (no keys)
```
- API 키 불필요
- 다나와에서 정확한 판매가 우선 조회, 실패 시 쿠팡 API 가격 자동 폴백
- 해외 IP 차단 우회를 위해 도쿄 리전 프록시 경유
Hard rules:
## MCP endpoint
- `COUPANG_MCP_ENDPOINT`는 호환성 knob로만 유지한다. 기본값은 `local://coupang-mcp`다.
- 구형 HF Space hosted MCP 엔드포인트를 사용하거나 새로 지어내지 않는다.
- upstream 저장소는 `https://github.com/retention-corp/coupang_partners.git`만 사용한다.
- `tools``init`은 로컬 MCP 계약 확인용으로 먼저 실행한다.
## Execution paths
`retention-corp/coupang_partners`는 하나의 CLI 뒤에서 두 가지 경로를 자동으로 선택한다. 래퍼(`coupang_partners_mcp.py`)는 두 경로 모두를 그대로 통과시킨다.
1. **Operator (local HMAC) path**`COUPANG_ACCESS_KEY``COUPANG_SECRET_KEY`가 둘 다 설정된 경우. upstream이 Coupang Partners API를 HMAC 서명해 직접 호출한다. 키/시크릿은 절대 답변·문서·커밋에 노출하지 않는다.
2. **Credentialless hosted fallback path** — 위 두 키 중 하나라도 없는 경우(또는 `OPENCLAW_SHOPPING_FORCE_HOSTED=1`). upstream이 자동으로 Retention Corp의 hosted 백엔드(`https://a.retn.kr/v1/public/assist`)로 떨어진다. 이 경로는 `X-OpenClaw-Client-Id` allowlist로 게이트되어 있으며, upstream이 기본으로 실어 보내는 `openclaw-skill` 값이 현재 Retention Corp allowlist에 등록된 값이다. k-skill 래퍼는 `OPENCLAW_SHOPPING_CLIENT_ID`를 별도로 설정하지 않고 이 upstream 기본값을 그대로 사용한다.
두 경로 모두 JSON envelope(`ok`/`data.session_id`/`data.tool`/`data.payload`/`data.result`) 모양은 동일하므로, 답변 로직은 경로를 구별할 필요가 없다. short deeplink는 hosted fallback에서는 `https://a.retn.kr/s/...` 형태로, operator path에서는 `https://link.coupang.com/...` 형태로 온다.
### 관련 환경변수
| 환경변수 | 역할 | 기본값 |
|---------|------|--------|
| `COUPANG_ACCESS_KEY`, `COUPANG_SECRET_KEY` | 운영자 Coupang Partners API 크리덴셜. 둘 다 있을 때만 로컬 HMAC 경로가 활성화된다. | 없음 (없으면 hosted fallback) |
| `OPENCLAW_SHOPPING_CLIENT_ID` | hosted fallback이 보낼 `X-OpenClaw-Client-Id`. upstream이 `openclaw-skill`을 기본으로 실어 보내며 이 값이 현재 Retention Corp allowlist에 등록되어 있다. k-skill 래퍼는 이 변수를 오버라이드하지 않는 것을 권장한다. | `openclaw-skill` |
| `OPENCLAW_SHOPPING_FORCE_HOSTED` | `1`이면 키가 있어도 hosted 경로를 강제한다. | 비어있음 |
| `OPENCLAW_SHOPPING_BASE_URL` | hosted 백엔드 base URL 오버라이드. 스테이징/로컬 backend 테스트용. | `https://a.retn.kr` |
## MCP endpoint / contract
```
https://yuju777-coupang-mcp.hf.space/mcp
local://coupang-mcp
```
프로토콜 호환 버전: MCP `2025-03-26`. 네트워크로 붙는 Streamable HTTP 서버가 아니라, upstream 저장소의 로컬 MCP 호환 CLI가 같은 도구 이름과 JSON-RPC 모양의 payload를 반환한다.
## When to use
- "쿠팡에서 생수 가격 좀 찾아줘"
@ -55,6 +80,7 @@ https://yuju777-coupang-mcp.hf.space/mcp
- 로그인, 장바구니, 결제 자동화가 필요한 경우
- 쿠팡 계정/session 접근이 필요한 경우
- 실시간 재고/품절 여부를 100% 보장해야 하는 경우 (hosted fallback과 Partners API 모두 캐시·지연이 있을 수 있다)
## Workflow
@ -64,73 +90,125 @@ https://yuju777-coupang-mcp.hf.space/mcp
- 권장 질문: `어떤 용도/예산/브랜드/용량을 우선할까요?`
### 2. Initialize MCP session
### 2. Bootstrap and check the tool contract
coupang-mcp는 MCP Streamable HTTP 프로토콜을 사용한다. 세션을 초기화한 뒤 도구를 호출한다.
래퍼는 기본적으로 `~/.cache/k-skill/coupang_partners`에 upstream 저장소를 clone한다. 이미 clone되어 있으면 그대로 사용하고, 최신화가 필요할 때만 `--update`를 붙인다.
```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')
python3 coupang-product-search/scripts/coupang_partners_mcp.py tools
python3 coupang-product-search/scripts/coupang_partners_mcp.py init
```
기존 checkout을 명시하거나 CI/검증에서 네트워크 clone을 막으려면:
```bash
python3 coupang-product-search/scripts/coupang_partners_mcp.py \
--repo-dir /path/to/coupang_partners \
--no-clone \
tools
python3 coupang-product-search/scripts/coupang_partners_mcp.py \
--repo-dir /path/to/coupang_partners \
--no-clone \
init
```
### 3. Call tools
세션 ID를 얻은 뒤 `tools/call` 로 원하는 도구를 호출한다.
구체적인 사용자 요청에 맞춰 upstream CLI 명령을 호출한다. 결과는 `ok`, `data.tool`, `data.payload`, `data.result`를 포함하는 JSON으로 반환된다.
```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: //'
# 일반 검색 (키 없이도 hosted fallback으로 작동)
python3 coupang-product-search/scripts/coupang_partners_mcp.py search "32인치 4K 모니터"
# 로켓배송 필터
python3 coupang-product-search/scripts/coupang_partners_mcp.py rocket "에어팟"
# 가격대 검색
python3 coupang-product-search/scripts/coupang_partners_mcp.py budget "키보드" --max-price 100000
# 비교
python3 coupang-product-search/scripts/coupang_partners_mcp.py compare "아이패드 vs 갤럭시탭"
# 골드박스 (운영자 키가 필요한 upstream 경로)
python3 coupang-product-search/scripts/coupang_partners_mcp.py goldbox
```
### 4. (optional) hosted fallback 강제
운영자 키가 있는 상태에서도 hosted fallback 경로를 점검하고 싶으면 `OPENCLAW_SHOPPING_FORCE_HOSTED=1`만 추가하면 된다. `OPENCLAW_SHOPPING_CLIENT_ID`는 upstream이 보내는 기본값 `openclaw-skill`이 현재 Retention Corp allowlist에 등록된 값이므로 별도로 설정하지 않는다.
```bash
export OPENCLAW_SHOPPING_FORCE_HOSTED=1
python3 coupang-product-search/scripts/coupang_partners_mcp.py search "에어팟"
```
## 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` | 당일 특가 정보 | `{}` |
| 도구명 | CLI 명령 | 기능 | 파라미터 예시 |
|--------|----------|------|-------------|
| `search_coupang_products` | `search` | 일반 상품 검색 | `"생수"` |
| `search_coupang_rocket` | `rocket` | 로켓배송만 필터링 | `"에어팟"` |
| `search_coupang_budget` | `budget` | 가격대 범위 검색 | `"키보드" --max-price 100000` |
| `compare_coupang_products` | `compare` | 상품 비교표 생성 | `"아이패드 vs 갤럭시탭"` |
| `get_coupang_recommendations` | `recommendations` | 인기 검색어 제안 | `--category 전자제품` |
| `get_coupang_seasonal` | `seasonal` | 계절/상황별 추천 | `"설날 선물"` |
| `get_coupang_best_products` | `best` | 카테고리별 베스트 | `--category-id 1016` |
| `get_coupang_goldbox` | `goldbox` | 당일 특가 정보 | `--limit 10` |
주의: `get_coupang_goldbox``get_coupang_best_products`는 upstream 기준 Coupang Partners API 권한이 필요한 경로이므로, 키가 없는 환경에서는 실패할 수 있다. 이런 경우 에러 메시지를 그대로 전달하고 hosted fallback이 커버하는 `search`/`rocket`/`budget`/`compare` 경로로 우회 제안한다.
## Response format
결과는 로켓배송(rocket)과 일반배송(normal)으로 구분되어 반환된다.
upstream CLI는 JSON을 출력한다. `data.result` 안의 상품 배열 또는 도구별 객체를 읽고, 답변에서는 로켓배송(rocket)과 일반배송(normal)을 구분한다.
```json
{
"ok": true,
"data": {
"session_id": "session-...",
"tool": "search_coupang_products",
"payload": {
"jsonrpc": "2.0",
"result": {
"content": [
{"type": "text", "text": "[...]"}
]
}
},
"result": []
}
}
```
사용자에게 보여줄 때는 다음처럼 짧게 정리한다.
```
## rocket (6)
## rocket (상위 후보)
1) LG전자 4K UHD 모니터
옵션: 80cm / 32UR500K
가격: 397,750원 (39만원대)
보러가기: https://link.coupang.com/a/...
가격: 397,750원 (참고용)
보러가기: https://a.retn.kr/s/... # hosted fallback shortlink
또는: https://link.coupang.com/a/... # operator HMAC 경로 딥링크
## normal (4)
## normal (상위 후보)
1) 삼성전자 QHD 오디세이 G5 게이밍 모니터
가격: 283,000원 (28만원대)
보러가기: https://link.coupang.com/a/...
가격: 283,000원 (참고용)
보러가기: https://a.retn.kr/s/...
```
## Response policy
- 후보가 여러 개면 상위 3~5개만 짧게 비교한다.
- 로켓배송/일반배송 구분을 명시한다.
- 가격은 참고용임을 안내한다 (다나와 실패 시 쿠팡 API 추정가).
- MCP 서버가 응답하지 않으면 서버 상태를 알리고 나중에 재시도를 권한다.
- 가격/품절/배송 정보는 실시간 변동될 수 있음을 안내한다.
- upstream checkout, 권한, Coupang Partners 환경변수 문제로 실패하면 실패 원인과 재시도/설정 방법을 짧게 안내한다.
- **Affiliate 고지(필수)**: 응답에 포함되는 shortlink(`https://a.retn.kr/s/...`)와 직접 coupang 딥링크(`link.coupang.com/...?lptag=AF...`)는 Retention Corp의 쿠팡 파트너스(affiliate) 채널로 트래킹된다. upstream이 돌려주는 `disclosure` 문자열(`"파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음"`)이 있으면 그대로 노출하고, 없으면 같은 취지의 고지를 답변 말미에 덧붙인다.
## Done when
- `tools``init` 또는 실제 명령으로 retention-corp/coupang_partners 로컬 MCP 계약을 확인했다.
- 검색 결과가 로켓배송/일반배송으로 구분되어 정리되었다.
- 사용자 니즈에 맞는 추천 TOP 3이 제시되었다.
- 가격/배송 정보가 포함되었다.
- 가격/배송 정보와 변동 가능성 안내가 포함되었다.
- affiliate 고지(disclosure)가 답변에 포함되었다.

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Bootstrap and run retention-corp/coupang_partners Coupang MCP tools.
The k-skill repo intentionally does not vendor the third-party implementation.
This wrapper keeps the skill pointed at the approved upstream repository, clones it
into a user cache when needed, and then delegates to its local MCP-compatible CLI.
"""
from __future__ import annotations
import argparse
import os
import pathlib
import subprocess
import sys
from typing import Sequence
UPSTREAM_REPO_URL = "https://github.com/retention-corp/coupang_partners.git"
DEFAULT_MCP_ENDPOINT = "local://coupang-mcp"
DEFAULT_REPO_DIR = pathlib.Path(os.getenv("COUPANG_PARTNERS_REPO_DIR", "~/.cache/k-skill/coupang_partners")).expanduser()
UPSTREAM_CLI = pathlib.Path("bin") / "coupang_mcp.py"
class BootstrapError(RuntimeError):
"""Raised when the upstream checkout cannot be prepared."""
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run the retention-corp/coupang_partners local Coupang MCP-compatible CLI.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" coupang_partners_mcp.py tools\n"
" coupang_partners_mcp.py init\n"
" coupang_partners_mcp.py search 생수\n"
" coupang_partners_mcp.py budget 키보드 --max-price 100000\n"
"\n"
"Honored upstream environment variables (forwarded as-is):\n"
" COUPANG_ACCESS_KEY, COUPANG_SECRET_KEY\n"
" Operator Coupang Partners API credentials. When set, upstream\n"
" uses the local HMAC-signed Coupang Partners path.\n"
" OPENCLAW_SHOPPING_CLIENT_ID\n"
" Allowlisted client id for the hosted fallback. upstream sends\n"
" openclaw-skill by default, which is the value currently on the\n"
" Retention Corp allowlist; k-skill does not override this.\n"
" OPENCLAW_SHOPPING_FORCE_HOSTED=1\n"
" Force the hosted fallback even when Coupang keys are present.\n"
" OPENCLAW_SHOPPING_BASE_URL\n"
" Override the hosted backend base URL. Default upstream target\n"
" is https://a.retn.kr and /v1/public/assist is the public entry.\n"
"\n"
"When both COUPANG_ACCESS_KEY and COUPANG_SECRET_KEY are missing,\n"
"upstream falls back to the hosted Retention Corp backend so this\n"
"skill keeps working without Coupang Partners credentials."
),
)
parser.add_argument(
"--repo-dir",
default=str(DEFAULT_REPO_DIR),
help="Checkout directory for retention-corp/coupang_partners (default: %(default)s).",
)
parser.add_argument(
"--no-clone",
action="store_true",
help="Do not clone the upstream repository if it is missing; fail with setup guidance instead.",
)
parser.add_argument(
"--update",
action="store_true",
help="Run git pull --ff-only in an existing upstream checkout before delegating.",
)
parser.add_argument(
"upstream_args",
nargs=argparse.REMAINDER,
help="Arguments passed to bin/coupang_mcp.py, for example: tools, search 생수, rocket 에어팟.",
)
args = parser.parse_args(argv)
if args.upstream_args and args.upstream_args[0] == "--":
args.upstream_args = args.upstream_args[1:]
if not args.upstream_args:
parser.error("missing upstream command; try: tools, init, search <keyword>, rocket <keyword>, budget <keyword>")
return args
def upstream_cli_path(repo_dir: pathlib.Path) -> pathlib.Path:
return repo_dir / UPSTREAM_CLI
def ensure_repo(repo_dir: pathlib.Path, *, clone: bool = True, update: bool = False) -> pathlib.Path:
cli_path = upstream_cli_path(repo_dir)
if cli_path.exists():
if update:
run_checked(["git", "-C", str(repo_dir), "pull", "--ff-only"], "failed to update upstream checkout")
return cli_path
if repo_dir.exists():
raise BootstrapError(
f"{repo_dir} exists but does not look like retention-corp/coupang_partners "
f"(missing {UPSTREAM_CLI}). Recreate it with: git clone {UPSTREAM_REPO_URL} {repo_dir}"
)
if not clone:
raise BootstrapError(
f"Missing retention-corp/coupang_partners checkout at {repo_dir}. "
f"Create it with: git clone {UPSTREAM_REPO_URL} {repo_dir}"
)
repo_dir.parent.mkdir(parents=True, exist_ok=True)
run_checked(["git", "clone", "--depth", "1", UPSTREAM_REPO_URL, str(repo_dir)], "failed to clone upstream checkout")
if not cli_path.exists():
raise BootstrapError(f"Cloned {UPSTREAM_REPO_URL}, but {UPSTREAM_CLI} was not found in {repo_dir}")
return cli_path
def run_checked(command: Sequence[str], context: str) -> None:
try:
subprocess.run(command, check=True)
except FileNotFoundError as exc:
raise BootstrapError(f"{context}: required executable not found: {command[0]}") from exc
except subprocess.CalledProcessError as exc:
raise BootstrapError(f"{context}: {exc}") from exc
def build_command(cli_path: pathlib.Path, upstream_args: Sequence[str]) -> list[str]:
return [sys.executable, str(cli_path), *upstream_args]
def main(argv: Sequence[str] | None = None) -> int:
args = parse_args(argv)
repo_dir = pathlib.Path(args.repo_dir).expanduser().resolve()
try:
cli_path = ensure_repo(repo_dir, clone=not args.no_clone, update=args.update)
except BootstrapError as exc:
print(f"coupang_partners_mcp.py: {exc}", file=sys.stderr)
return 2
env = os.environ.copy()
env.setdefault("COUPANG_MCP_ENDPOINT", DEFAULT_MCP_ENDPOINT)
completed = subprocess.run(build_command(cli_path, args.upstream_args), env=env)
return int(completed.returncode)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -2,9 +2,9 @@
## 이 기능으로 할 수 있는 일
[coupang-mcp](https://github.com/uju777/coupang-mcp) 서버를 통해 쿠팡 상품을 검색하고 실시간 가격을 확인한다.
[retention-corp/coupang_partners](https://github.com/retention-corp/coupang_partners)의 로컬 Coupang MCP 호환 레이어를 이용해 쿠팡 상품 조회 도구를 실행한다. 기존 HF Space 기반 `coupang-mcp` 서버 대신 upstream 저장소의 `bin/coupang_mcp.py``local://coupang-mcp` 계약으로 호출한다.
- 키워드 상품 검색 (로켓배송/일반배송 구분)
- 키워드 상품 검색
- 로켓배송 전용 필터 검색
- 가격대 범위 검색
- 상품 비교표 생성
@ -14,90 +14,153 @@
## 동작 방식
```
Claude Code → MCP JSON-RPC → HF Space (coupang-mcp) → Netlify 프록시 (도쿄) → 다나와/쿠팡
Codex/Claude Code → coupang_partners_mcp.py → retention-corp/coupang_partners checkout → bin/coupang_mcp.py → local://coupang-mcp
├─ operator: Coupang Partners API (local HMAC)
└─ credentialless: hosted fallback → https://a.retn.kr/v1/public/assist
```
- **API 키 불필요** — coupang-mcp가 다나와 가격 조회를 1차로, 쿠팡 API를 폴백으로 사용
- 해외 IP 차단 우회를 위해 도쿄 리전 Netlify 프록시 경유
- **구형 hosted endpoint 제거** — 이전 HF Space 기반 MCP 서버를 사용하지 않는다.
- **upstream 고정** — 래퍼는 `https://github.com/retention-corp/coupang_partners.git`를 clone/update한 뒤 upstream CLI에 위임한다.
- **이중 실행 경로**`COUPANG_ACCESS_KEY`/`COUPANG_SECRET_KEY`가 둘 다 있으면 upstream이 로컬 HMAC으로 Coupang Partners API를 호출하고, 없으면 자동으로 Retention Corp의 hosted 백엔드(`https://a.retn.kr/v1/public/assist`)로 떨어져 공개 추천/검색을 반환한다(hosted fallback). 래퍼는 두 경로를 자동 선택한다.
- **allowlist** — hosted fallback은 `X-OpenClaw-Client-Id` allowlist로 게이트되어 있다. upstream이 기본으로 실어 보내는 `openclaw-skill` 값이 현재 Retention Corp allowlist에 등록되어 있어 credentialless 호출이 200을 받는다. k-skill 래퍼는 이 기본값을 그대로 사용하고 `OPENCLAW_SHOPPING_CLIENT_ID`를 오버라이드하지 않는다. Retention Corp 측 allowlist 정책이 바뀌면 그때 맞춰 가이드를 갱신한다.
- **secret은 runtime 환경변수** — 운영자 모드에서는 `COUPANG_ACCESS_KEY`, `COUPANG_SECRET_KEY`를 환경변수로 주입한다. 키를 저장소나 답변에 노출하지 않는다.
- **계약 확인 우선**`tools`/`init` 명령으로 로컬 MCP 호환 도구 목록과 JSON-RPC payload 형태를 먼저 확인한다.
## MCP 엔드포인트
## MCP 계약
```
https://yuju777-coupang-mcp.hf.space/mcp
local://coupang-mcp
```
프로토콜: MCP Streamable HTTP (JSON-RPC 2.0)
프로토콜 호환 버전: MCP `2025-03-26`. 네트워크 Streamable HTTP 서버가 아니라 upstream 저장소의 로컬 CLI가 같은 도구 이름을 제공한다.
## 환경변수
| 환경변수 | 역할 | 기본값 |
|---------|------|--------|
| `COUPANG_ACCESS_KEY`, `COUPANG_SECRET_KEY` | 운영자 Coupang Partners API 크리덴셜. 둘 다 있을 때만 로컬 HMAC 경로가 활성화된다. | 없음 (없으면 hosted fallback) |
| `OPENCLAW_SHOPPING_CLIENT_ID` | hosted fallback의 `X-OpenClaw-Client-Id`. upstream이 `openclaw-skill`을 기본으로 실어 보내며 이 값이 현재 Retention Corp allowlist에 등록되어 있다. k-skill 래퍼는 이 변수를 오버라이드하지 않는다. | `openclaw-skill` |
| `OPENCLAW_SHOPPING_FORCE_HOSTED` | `1`이면 키가 있어도 hosted 경로를 강제한다. | 비어있음 |
| `OPENCLAW_SHOPPING_BASE_URL` | hosted 백엔드 base URL 오버라이드. 스테이징/로컬 backend 테스트용. | `https://a.retn.kr` |
k-skill 쪽 래퍼(`coupang_partners_mcp.py`)는 위 환경변수를 **오버라이드/디폴트 설정 없이 그대로 upstream에 전달**한다. 사용자가 export한 값이 최종 결정을 가져간다.
## 사용 가능한 도구
| 도구명 | 기능 | 사용 예시 |
|--------|------|----------|
| `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` | 당일 특가 정보 | "오늘 특가 뭐있어?" |
| 도구명 | CLI 명령 | 기능 | 사용 예시 |
|--------|----------|------|----------|
| `search_coupang_products` | `search` | 일반 상품 검색 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py search "맥북"` |
| `search_coupang_rocket` | `rocket` | 로켓배송만 필터링 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py rocket "에어팟"` |
| `search_coupang_budget` | `budget` | 가격대 범위 검색 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py budget "키보드" --max-price 100000` |
| `compare_coupang_products` | `compare` | 상품 비교표 생성 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py compare "아이패드 vs 갤럭시탭"` |
| `get_coupang_recommendations` | `recommendations` | 인기 검색어 제안 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py recommendations --category 전자제품` |
| `get_coupang_seasonal` | `seasonal` | 계절/상황별 추천 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py seasonal "설날 선물"` |
| `get_coupang_best_products` | `best` | 카테고리별 베스트 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py best --category-id 1016` |
| `get_coupang_goldbox` | `goldbox` | 당일 특가 정보 | `python3 coupang-product-search/scripts/coupang_partners_mcp.py goldbox --limit 10` |
주의: `get_coupang_goldbox``get_coupang_best_products`는 upstream 기준 Coupang Partners API 권한이 필요한 경로이므로, 키 없이 hosted fallback으로만 실행 중이면 실패할 수 있다. 실패 메시지를 그대로 전달하고 hosted fallback이 커버하는 `search`/`rocket`/`budget`/`compare`로 우회 제안한다.
## 기본 흐름
1. 검색어를 받는다. 너무 넓으면 용도/예산/브랜드를 먼저 물어본다.
2. MCP 세션을 초기화한다 (`initialize``Mcp-Session-Id` 확보).
3. `tools/call`로 적절한 도구를 호출한다.
4. 결과를 로켓배송/일반배송으로 구분하여 정리한다.
5. 상위 3~5개 추천과 함께 가격/배송 정보를 제공한다.
2. `tools``init` 명령으로 retention-corp/coupang_partners 로컬 MCP 도구 목록과 handshake payload를 확인한다.
3. 요청에 맞는 CLI 명령을 실행한다(키가 없어도 hosted fallback으로 `search`/`rocket`/`budget`/`compare`는 작동한다).
4. `data.result`를 읽고 로켓배송/일반배송을 구분하여 정리한다.
5. 상위 3~5개 추천과 함께 가격/배송 정보, 변동 가능성, affiliate 고지를 제공한다.
## 호출 예시
```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 확보
# 1. 최초 실행: upstream checkout을 자동 clone하고 도구 목록 확인
python3 coupang-product-search/scripts/coupang_partners_mcp.py tools
python3 coupang-product-search/scripts/coupang_partners_mcp.py init
# 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":"생수"}
}}'
# 2. 이미 clone된 upstream을 명시해서 네트워크 없이 계약 확인
python3 coupang-product-search/scripts/coupang_partners_mcp.py \
--repo-dir ~/.cache/k-skill/coupang_partners \
--no-clone \
tools
python3 coupang-product-search/scripts/coupang_partners_mcp.py \
--repo-dir ~/.cache/k-skill/coupang_partners \
--no-clone \
init
# 3. 기존 checkout을 fast-forward로 최신화한 뒤 계약 확인
python3 coupang-product-search/scripts/coupang_partners_mcp.py \
--repo-dir ~/.cache/k-skill/coupang_partners \
--update \
tools
# 4. 상품 검색 (키 없이도 hosted fallback으로 동작)
python3 coupang-product-search/scripts/coupang_partners_mcp.py search "생수"
# 5. 로켓배송 필터
python3 coupang-product-search/scripts/coupang_partners_mcp.py rocket "에어팟"
# 6. hosted fallback 강제 (upstream 기본 allowlist client-id 유지)
OPENCLAW_SHOPPING_FORCE_HOSTED=1 \
python3 coupang-product-search/scripts/coupang_partners_mcp.py search "무선청소기"
```
## 결과 형식
upstream CLI는 다음과 같은 JSON envelope를 반환한다.
```json
{
"ok": true,
"data": {
"session_id": "session-...",
"tool": "search_coupang_products",
"payload": {
"jsonrpc": "2.0",
"result": {
"content": [
{"type": "text", "text": "[...]"}
]
}
},
"result": []
}
}
```
## rocket (6)
- hosted fallback 경로는 각 상품의 `productUrl``https://a.retn.kr/s/...` 형태의 short deeplink를 붙여 돌려준다.
- operator (로컬 HMAC) 경로는 `https://link.coupang.com/...?lptag=AF...` 형태의 직접 딥링크를 돌려준다.
- 두 경로 모두 Retention Corp의 쿠팡 파트너스 채널로 트래킹된다. affiliate 고지를 반드시 포함한다.
사용자 답변은 짧은 비교표 형태로 정리한다.
```
## rocket (상위 후보)
1) LG전자 4K UHD 모니터
옵션: 80cm / 32UR500K
가격: 397,750원 (39만원대)
보러가기: https://link.coupang.com/a/...
가격: 397,750원 (참고용)
보러가기: https://a.retn.kr/s/...
## normal (4)
## normal (상위 후보)
1) 삼성전자 QHD 오디세이 G5 게이밍 모니터
가격: 283,000원 (28만원대)
보러가기: https://link.coupang.com/a/...
가격: 283,000원 (참고용)
보러가기: https://a.retn.kr/s/...
```
## Affiliate 고지 (필수)
hosted fallback이 반환하는 응답에는 `disclosure` 필드가 포함될 수 있다. 예시 문구: `"파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음"`. 이 문구가 오면 **답변에 그대로 노출**한다. 응답에 disclosure가 없더라도 `a.retn.kr/s/` shortlink나 `lptag=AF` 딥링크가 포함되는 이상 같은 취지의 문구를 반드시 포함한다.
## 제한사항
- 가격은 참고용이다. 다나와 조회 실패 시 쿠팡 API 추정가가 표시된다.
- 가격/품절/배송 정보는 실시간으로 바뀔 수 있다.
- 로그인, 장바구니, 결제 자동화는 지원하지 않는다.
- MCP 서버(HF Space)가 다운되면 일시적으로 사용 불가하다.
- hosted fallback(`https://a.retn.kr/v1/public/assist`)은 allowlist로 게이트되어 있어 upstream 정책에 따라 응답 형태나 client-id 요구가 바뀔 수 있다.
- `goldbox`/`best-products` 등 Coupang Partners API 권한이 필요한 도구는 hosted fallback에서는 대체되지 않으므로 실패할 수 있다.
- upstream checkout이 없고 네트워크 clone도 막힌 환경에서는 `--repo-dir`로 기존 checkout을 지정해야 한다.
## 출처
- [coupang-mcp GitHub](https://github.com/uju777/coupang-mcp)
- MCP 엔드포인트: `https://yuju777-coupang-mcp.hf.space/mcp`
- [retention-corp/coupang_partners GitHub](https://github.com/retention-corp/coupang_partners)
- 로컬 MCP 계약: `local://coupang-mcp`
- hosted fallback endpoint: `https://a.retn.kr/v1/public/assist`
- hosted fallback을 upstream에 추가한 PR: <https://github.com/retention-corp/coupang_partners/pull/1> (merged)
- 래퍼: `coupang-product-search/scripts/coupang_partners_mcp.py`

View file

@ -25,10 +25,16 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/naver-shopping/search` (네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 공개 BFF JSON 기반 상품/가격 후보 조회)
- `GET /v1/opinet/around`
- `GET /v1/opinet/detail`
- `GET /v1/neis/school-search` (나이스 학교기본정보, `KEDU_INFO_KEY`)
- `GET /v1/neis/school-meal` (나이스 급식식단정보, `KEDU_INFO_KEY`)
- `GET /v1/data4library/library-search` (도서관 정보나루 정보공개 도서관 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-search` (도서관 정보나루 도서 검색, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-detail` (도서관 정보나루 도서 상세 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` (도서 소장 도서관 조회, `DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` (도서관별 도서 소장여부, `DATA4LIBRARY_AUTH_KEY`)
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
@ -47,7 +53,9 @@ client/skill -> k-skill-proxy -> upstream public API
- `DATA_GO_KR_API_KEY=...`
- `FOODSAFETYKOREA_API_KEY=...` (선택: 식품안전나라 회수 live 결과, 없으면 sample fallback)
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
- `DATA4LIBRARY_AUTH_KEY=...` (도서관 정보나루 Open API 인증키)
- `KRX_API_KEY=...`
- `NAVER_SEARCH_CLIENT_ID=...`, `NAVER_SEARCH_CLIENT_SECRET=...` (선택: 네이버 검색 Open API 쇼핑 검색)
- `KSKILL_PROXY_PORT=4020`
## 프로덕션 배포 구조
@ -185,6 +193,49 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
--data-urlencode 'limit=5'
```
도서관 정보나루 도서 검색 endpoint (`DATA4LIBRARY_AUTH_KEY` 필요):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-search' \
--data-urlencode 'keyword=역사' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'pageSize=10'
```
도서 상세/소장 조회 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-detail' \
--data-urlencode 'isbn13=9788971998557' \
--data-urlencode 'loaninfoYN=Y'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/libraries-by-book' \
--data-urlencode 'isbn=9788971998557' \
--data-urlencode 'region=11'
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-exists' \
--data-urlencode 'libraryCode=111001' \
--data-urlencode 'isbn13=9788971998557'
```
프록시는 caller가 넘긴 `authKey`/`format`을 무시하고 서버 쪽 `DATA4LIBRARY_AUTH_KEY``format=json`을 주입한다.
네이버 쇼핑 가격비교 endpoint (`NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET`이 있으면 공식 Search API 우선):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/naver-shopping/search' \
--data-urlencode 'q=에어팟 프로 2세대' \
--data-urlencode 'limit=10'
```
키가 없는 no-key fallback은 `search.shopping.naver.com/search/all` HTML 페이지 대신
`ns-portal.shopping.naver.com/api/v2/shopping-paged-slot?query=<검색어>&source=shp_gui`
공개 JSON path를 사용한다. `page`는 BFF에 전달한 뒤 해당 페이지 카드만 정규화하고, no-key
`price_asc`/`price_dsc`/`review` 정렬은 선택된 BFF 페이지 안에서 로컬 적용한다. BFF에는 날짜
필드가 없어 no-key `date` 요청은 `meta.sort_applied: "unsupported"`로 표시한다.
한국 주식 검색 endpoint:
```bash
@ -220,5 +271,6 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
- upstream key는 프록시 서버에서만 관리합니다.
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- 도서관 정보나루 route도 사용자에게 `DATA4LIBRARY_AUTH_KEY` 를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철/한국 날씨 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 한강 수위 route도 local/self-host 또는 배포 확인이 끝난 proxy URL 로 검증합니다.

View file

@ -0,0 +1,87 @@
# 한국 개인정보처리방침·이용약관 자동 생성 가이드
## 이 기능으로 할 수 있는 일
- Next.js (13 ~ 16) App Router 프로젝트에 **개인정보처리방침 페이지** 생성
- 동일 프로젝트에 **이용약관 페이지** 생성 (공정위 전자상거래 표준약관 제10023호 기반)
- shadcn/ui 기반 **쿠키 배너**, **회원가입 동의 모달**, **라벨링 카드** 컴포넌트 설치
- 서비스 유형(SaaS · 쇼핑몰 · 커뮤니티 · 블로그 · 핀테크 · AI) 별 분기
- 한국어 / 영문 / 한영 병기 출력 (GDPR 관할 추가 시 EU 분기 지원)
## 가장 중요한 규칙
본 스킬은 [`kimlawtech/korean-privacy-terms`](https://github.com/kimlawtech/korean-privacy-terms) (Apache-2.0) 업스트림의 **thin wrapper** 이다. k-skill 측은 발견 · 인용 · 법률 면책 · 설치 진입점만 책임지고, 인터뷰 · 렌더 · 파일 생성 로직은 업스트림에 위임한다.
생성되는 모든 문서는 **참고용 초안**이며 **법률 자문이 아니다**. 실서비스 배포 전 반드시 **변호사 검토**를 받아야 한다. 2026.9.11 시행 개정 개인정보보호법 기준(§30 법정 항목 확장, 과징금 매출액 최대 10%, 사업주 책임)이 업스트림 pin 시점으로 반영돼 있으며, 이후 개정 반영은 upstream pin 을 bump 해 갱신한다.
## 먼저 필요한 것
- 인터넷 연결 (업스트림 clone 용; 오프라인 환경에서는 동작하지 않는다)
- `git` 2.20+
- `bash`
- `node` 18+ (업스트림이 권장하는 Next.js 실행 요건)
- 대상 프로젝트가 Next.js 13 ~ 16 App Router 기반 (Pages Router 단독 프로젝트는 업스트림이 실행 중단)
## 설치 흐름
dual-install: `~/.claude/skills/korean-privacy-terms/upstream/``~/.agents/skills/korean-privacy-terms/upstream/` 양쪽에 pinned SHA 로 업스트림을 체크아웃한다.
```bash
bash korean-privacy-terms/scripts/install.sh
```
설치 확인:
```bash
git -C ~/.claude/skills/korean-privacy-terms/upstream rev-parse HEAD
git -C ~/.agents/skills/korean-privacy-terms/upstream rev-parse HEAD
cat korean-privacy-terms/scripts/upstream.pin
```
세 값이 모두 동일한 40자리 SHA 여야 한다. AGENTS.md 규칙에 따라 레포 내부에 repo-local `.claude/` 또는 `.agents/` 디렉토리는 생성하지 않는다. `~/.agents/skills` 가 symlink 로 관리되는 환경에서는 그 indirection 을 존중한다.
## upstream pin 갱신
법령 개정 / 업스트림 CHANGELOG 업데이트가 반영되면 `korean-privacy-terms/scripts/upstream.pin` 을 최신 tag SHA 로 수동 교체한 뒤 PR 을 올린다.
```bash
git ls-remote --tags https://github.com/kimlawtech/korean-privacy-terms.git
```
새 SHA 를 `upstream.pin` 에 덮어쓰고 `bash korean-privacy-terms/scripts/install.sh` 를 다시 실행해 양쪽 홈 경로가 새 SHA 로 갱신되는지 확인한다. pin 만 올리지 말고 upstream CHANGELOG 를 함께 읽어 법률 개정 포인트를 PR 본문에 기록한다.
## 인터뷰 게이트 (필수)
사용자가 "개인정보처리방침 만들어줘" 처럼 생성을 요청하면 바로 파일을 만들지 말고 업스트림 `scripts/interview.md` 프로토콜을 따라 **먼저 되묻는다**. 최소한 아래를 확인한 뒤 생성 단계로 넘어간다.
- 대상 사용자 범위 — 한국만 / 해외 위주 / 양쪽 글로벌 (관할법 결정)
- 운영 주체 소재지 — 한국 / 해외
- 서비스 유형 — SaaS / 쇼핑몰 / 커뮤니티 / 블로그 / 핀테크 / AI
- Next.js 버전 (13 ~ 16), App Router 여부, `.ts` / `.js`
- Tailwind 버전 (v3 / v4), 번들러 (Turbopack / Webpack)
- shadcn/ui 기설치 여부, 기존 디자인 시스템
- 출력 언어 (한국어만 / 영문만 / 한영 병기)
- 14세 미만 대상 여부
- 자동화된 결정(AI) · 행태정보(쿠키) · 맞춤형 광고 처리 여부
- 사업자 상호 · 대표자 · 주소, 개인정보 보호책임자(CPO) 정보
인터뷰는 agent-neutral 톤으로 한 번에 1 ~ 2문항씩 진행하고, 법률 공포감(과태료 폭탄 등) 유발 표현은 피한다. 모르는 항목은 "넘어가도 됩니다" 로 연다.
## 응답 정책
이 스킬이 실행한 모든 답변은 아래 고정 블록을 끝에 포함한다.
- 생성된 문서는 **참고용 초안**이며 법률 자문이 아니다.
- 실서비스 적용 전 **변호사 검토**가 필수이다.
- 2026.9.11 시행 개정 개인정보보호법 기준은 업스트림 pin 시점에 반영되었다. 이후 개정에 대한 최신 반영 여부는 사용자에게 확인 책임이 있다.
- 법정 필수 11개 항목(처리 목적 · 처리 항목 · 처리/보유 기간 · 파기 · 정보주체 권리 · 보호책임자 · 안전조치 · 방침 변경 · 열람/정정/삭제/처리정지 요구권 · 열람청구 부서 · 권익침해 구제) 중 하나라도 누락돼선 안 된다.
- 2025.4.21 개정 작성지침 반영 항목(전송요구권 · 자동화된 결정 · 행태정보 · 고충처리 부서)도 해당되면 반드시 포함한다.
## 라이선스 / 출처
- 업스트림: https://github.com/kimlawtech/korean-privacy-terms (Apache-2.0)
- upstream pin 파일: `korean-privacy-terms/scripts/upstream.pin`
- 업스트림 저자 · 커뮤니티 attribution: `korean-privacy-terms/NOTICE` (@kimlawtech, SpeciAI community)
- 법률 면책 전문 (한 / 영): `korean-privacy-terms/DISCLAIMER.md`
- Apache-2.0 전문 (업스트림 `LICENSE` verbatim 복사): `korean-privacy-terms/LICENSE.upstream` — Apache License, Version 2.0 §4(a) 는 재배포자가 "give any other recipients of the Work or Derivative Works a copy of this License" 하도록 요구한다. `install.sh` 실행 전에도 레포 체크아웃 상태에서 이 요건이 충족되도록 번들해 두었다.
- 레포 루트 `LICENSE` (MIT) 는 k-skill 자체 라이선스이며 이 스킬 하위에는 적용되지 않는다. 본 스킬은 업스트림 산출물의 재배포이므로 Apache License, Version 2.0 §4 요건 (LICENSE 번들, NOTICE 포함, 수정 고지) 을 준수한다.

View file

@ -0,0 +1,120 @@
# 도서관 도서 조회 가이드
## 이 기능으로 할 수 있는 일
도서관 정보나루(Data4Library) Open API를 `k-skill-proxy` 경유로 호출해 한국 공공도서관 도서 정보를 조회한다.
- 키워드 기반 도서 검색
- ISBN 기반 도서 상세 조회
- ISBN + 지역코드 기반 소장 도서관 조회
- 도서관 코드 + ISBN 기반 특정 도서관 소장/대출 가능 여부 조회
## 가장 중요한 규칙
사용자에게 필요한 시크릿은 없으며, `DATA4LIBRARY_AUTH_KEY`는 프록시 서버에서만 관리한다.
1. 사용자는 **`DATA4LIBRARY_AUTH_KEY`를 들고 있지 않는다.** `k-skill-proxy`만 upstream `authKey`를 붙인다.
2. 공개 read-only 조회만 한다. 도서관 로그인, 예약, 대출 자동화는 하지 않는다.
3. 도서관명·지역명이 애매하면 후보를 보여 주고 사용자가 선택하게 한다.
## 먼저 필요한 것
- 인터넷 연결
- 프록시 base URL (기본: `https://k-skill-proxy.nomadamas.org`)
- 프록시 서버 환경변수 `DATA4LIBRARY_AUTH_KEY` (self-host 운영자만 필요)
## 기본 조회 흐름
### 1) 도서 검색
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-search' \
--data-urlencode 'keyword=역사' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'pageSize=10'
```
`keyword` 대신 `q`/`query`, `pageNo`/`pageSize` 대신 `page`/`limit`도 쓸 수 있다. 결과에서 `bookname`, `authors`, `publisher`, `publication_year`, `isbn13`을 확인한다.
### 2) 도서 상세 조회
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-detail' \
--data-urlencode 'isbn13=9788971998557' \
--data-urlencode 'loaninfoYN=Y'
```
`loaninfoYN=Y`는 upstream이 제공하는 대출 관련 추가 정보를 함께 요청한다.
### 3) 소장 도서관 조회
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/libraries-by-book' \
--data-urlencode 'isbn=9788971998557' \
--data-urlencode 'region=11' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'pageSize=10'
```
`region`은 도서관 정보나루 지역코드다. 상세 지역코드가 있으면 `dtl_region`을 추가한다.
### 4) 특정 도서관 소장/대출 가능 여부 확인
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/book-exists' \
--data-urlencode 'libraryCode=111001' \
--data-urlencode 'isbn13=9788971998557'
```
### 5) 도서관 목록 조회(선택)
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/data4library/library-search' \
--data-urlencode 'region=11' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'pageSize=10'
```
## 프록시 응답 공통 메타
프록시는 JSON 응답에 다음 메타를 덧붙인다.
- `query`: 정규화된 요청값
- `proxy.name`: 프록시 이름
- `proxy.cache.hit`: 캐시 hit 여부
- `proxy.cache.ttl_ms`: 캐시 TTL
- `proxy.requested_at`: 요청 시각
## 파라미터 요약
| 엔드포인트 | 필수 쿼리 | 선택 쿼리 |
| --- | --- | --- |
| `/v1/data4library/book-search` | `keyword`(`q`, `query`) | `pageNo`, `pageSize` |
| `/v1/data4library/book-detail` | `isbn13` | `loaninfoYN=Y|N` |
| `/v1/data4library/libraries-by-book` | `isbn`, `region` | `dtl_region`, `pageNo`, `pageSize` |
| `/v1/data4library/book-exists` | `libraryCode`, `isbn13` | - |
| `/v1/data4library/library-search` | - | `region`, `dtl_region`, `libraryCode`, `pageNo`, `pageSize` |
## 자주 보는 필드
- `bookname`: 도서명
- `authors`: 저자
- `publisher`: 출판사
- `publication_year`: 출판연도
- `isbn13`: ISBN
- `libCode`: 도서관 코드
- `libName`: 도서관명
- `address`: 도서관 주소
- `hasBook`, `loanAvailable`: 특정 도서관 소장/대출 가능 여부(응답에 있는 경우)
## 실패/주의 사항
- `DATA4LIBRARY_AUTH_KEY`가 프록시 서버에 없으면 `503 upstream_not_configured`가 반환된다.
- ISBN은 하이픈을 제거했을 때 ISBN-10(마지막 X 허용) 또는 ISBN-13이어야 한다.
- Data4Library 데이터와 개별 도서관 대출 상태는 동기화 지연이 있을 수 있다. 방문 전 도서관 홈페이지나 전화로 최종 확인을 권한다.
- 지역코드·도서관 코드가 불명확하면 임의로 고르지 말고 후보를 제시한다.
## 참고 링크
- 도서관 정보나루 Open API 활용방법: `https://www.data4library.kr/apiUtilization`
- 프록시 구현·엔드포인트 목록: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -0,0 +1,105 @@
# 네이버 쇼핑 가격비교 가이드
## 이 기능으로 할 수 있는 일
- 네이버 쇼핑 상품 검색
- 현재 노출 가격과 판매처 비교
- 상품 링크/이미지/리뷰 수 등 공개 노출 메타데이터 정리
- 가격 후보를 3~5개로 줄여 보수적으로 추천
## 먼저 필요한 것
- 인터넷 연결
- `k-skill-proxy` 실행 또는 `KSKILL_PROXY_BASE_URL`
- 네이버 로그인/사용자 계정은 필요 없음
## 공식/공개 표면
- 공개 BFF JSON: `https://ns-portal.shopping.naver.com/api/v2/shopping-paged-slot?query=<검색어>&source=shp_gui`
- 프록시 endpoint: `GET /v1/naver-shopping/search`
프록시에 `NAVER_SEARCH_CLIENT_ID``NAVER_SEARCH_CLIENT_SECRET`이 있으면 네이버 검색 Open API의 쇼핑 검색(`shop.json`)을 우선 사용한다. 키가 없으면 네이버 검색/쇼핑 프론트엔드가 사용하는 공개 BFF JSON endpoint를 단일 요청으로 읽는다. BFF fallback은 스키마 변경이나 bot-block 응답이 있을 수 있으며, 접근 통제를 우회하지 않는다.
## 기본 호출
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/naver-shopping/search' \
--data-urlencode 'q=에어팟 프로 2세대' \
--data-urlencode 'limit=10' \
--data-urlencode 'sort=rel'
```
로컬 proxy:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/naver-shopping/search' \
--data-urlencode 'q=아이폰 15 케이스' \
--data-urlencode 'limit=5' \
--data-urlencode 'sort=price_asc'
```
## 입력값
- `q` 또는 `query`: 검색어. 2글자 이상.
- `limit`: 반환 개수. 기본 10, 최대 40.
- `page`: 페이지. 기본 1. no-key BFF fallback은 BFF `page`를 요청하고 해당 페이지 카드만 정규화한다.
- `sort`: `rel`, `date`, `price_asc`, `price_dsc`, `review`.
- 공식 Search API 경로는 네이버 API sort를 사용한다. 다만 공식 API가 `review` 정렬을 제공하지 않으므로 `review` 요청은 upstream `sort=sim`으로 조회하고 `meta.sort_applied: "unsupported"`, `meta.upstream_sort: "sim"`으로 표시한다.
- no-key BFF fallback은 `rel`은 BFF 노출 순서를 유지하고, `price_asc`/`price_dsc`/`review`는 선택된 BFF 페이지 안에서 로컬 정렬한다.
- BFF에는 날짜 정렬용 카드 필드가 없어 no-key `date` 요청은 `meta.sort_applied: "unsupported"`로 표시하고 BFF 노출 순서를 유지한다.
## 응답 예시
```json
{
"items": [
{
"rank": 1,
"product_id": "1234567890",
"title": "애플 정품 20W USB-C 전원 어댑터",
"price": 23900,
"price_text": "23,900원",
"mall_name": "Apple 공식스토어",
"url": "https://search.shopping.naver.com/catalog/1234567890",
"image_url": "https://shop-phinf.pstatic.net/main_1234567890.jpg",
"category": ["디지털/가전", "휴대폰액세서리"],
"review_count": 1234,
"purchase_count": 56,
"score": 4.8,
"is_ad": false,
"source": "bff-json"
}
],
"query": {
"q": "애플 어댑터",
"limit": 10,
"page": 1,
"sort": "rel"
},
"meta": {
"query": "애플 어댑터",
"extraction": "bff-json",
"item_count": 1,
"page": 1,
"sort": "rel",
"sort_applied": "upstream"
}
}
```
## 기본 흐름
1. 상품명/검색어가 없으면 먼저 물어본다.
2. `q`로 프록시를 호출한다.
3. 가격, 판매처, 링크가 있는 후보만 비교한다.
4. 가격 낮은 순으로 정리하되, 광고 여부/공식몰/리뷰 수를 함께 언급한다.
5. 가격과 재고는 조회 시점 기준이며 쿠폰·배송비·옵션가는 확정하지 않는다고 말한다.
## 운영 팁
- 모델명, 용량, 색상, 세대 정보를 검색어에 포함하면 가격 비교 품질이 좋아진다.
- 네이버가 특정 서버/IP에 418/403 등을 반환하면 같은 요청을 반복해 우회하지 말고 사용자에게 수동 확인 또는 검색어 조정을 안내한다.
- no-key fallback은 기존 `search.shopping.naver.com/search/all` HTML 페이지가 아니라 `ns-portal.shopping.naver.com/api/v2/shopping-paged-slot` JSON path를 사용한다.
- no-key fallback의 가격/리뷰 정렬은 BFF가 반환한 선택 페이지 내부의 로컬 정렬이다. 전체 네이버 쇼핑 결과에 대한 전역 최저가/최다리뷰 정렬이 필요하면 공식 Search API 키를 설정한다.
- proxy route는 public/read-only/no-auth이며 cache와 rate limit을 사용한다.
- 운영 환경에는 가능하면 `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET`을 설정해 공식 Search API 경로를 우선 사용한다.

View file

@ -0,0 +1,63 @@
# 근처 공영주차장 찾기
`parking-lot-search` 스킬은 사용자가 알려준 위치 기준으로 가까운 공영주차장을 찾는다.
## 핵심 원칙
- 위치를 자동 추적하지 않는다. 먼저 현재 위치를 질문한다.
- 기본 결과는 `공영` 주차장만 포함한다.
- 데이터 출처는 공공데이터포털 `전국주차장정보표준데이터` Open API다.
- 실시간 잔여 주차면, 만차 여부, 예약 가능 여부는 공식 표준데이터에 없으므로 단정하지 않는다.
## 사용 예
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공영주차장을 찾아볼게요.
```
위치를 받으면 `parking-lot-search` 패키지 또는 k-skill-proxy `/v1/parking-lots/search`를 사용한다.
## Node.js 예시
```js
const { searchNearbyParkingLotsByLocationQuery } = require("parking-lot-search");
async function main() {
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
limit: 3,
radius: 1500
});
console.log(result.anchor);
console.log(result.items.map((item) => ({
name: item.name,
distanceMeters: Math.round(item.distanceMeters),
feeInfo: item.feeInfo,
basicCharge: item.basicCharge,
mapUrl: item.mapUrl
})));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Proxy endpoint
```http
GET /v1/parking-lots/search?latitude=37.573713&longitude=126.978338&address_hint=서울특별시%20종로구&limit=3&radius=1500
```
응답은 가까운 주차장 목록, query echo, cache metadata를 포함한다. 운영 proxy에는 `DATA_GO_KR_API_KEY`가 필요하다.
## 모두의주차장
Issue #135에서 모두의주차장 연동 가능성이 언급되었지만 v1은 공식 공공데이터 기반으로 시작한다. 승인된 공식/파트너 API 계약이 확인되기 전에는 모두의주차장 비공식 API 호출이나 scraping을 하지 않는다.
## 참고 표면
- 표준데이터: <https://www.data.go.kr/data/15012896/standard.do>
- Open API: <https://www.data.go.kr/data/15012896/openapi.do>
- Endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`

View file

@ -54,6 +54,7 @@ npx --yes skills add <owner/repo> \
--skill lotto-results \
--skill kakaotalk-mac \
--skill korean-law-search \
--skill korean-privacy-terms \
--skill real-estate-search \
--skill korean-scholarship-search \
--skill korean-stock-search \
@ -81,6 +82,7 @@ npx --yes skills add <owner/repo> \
--skill bunjang-search \
--skill used-car-price-search \
--skill korean-spell-check \
--skill library-book-search \
--skill k-schoollunch-menu \
--skill korean-character-count
```
@ -129,6 +131,8 @@ korean-law list
`household-waste-info` 는 별도 설치 없이 `k-skill-proxy``/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서만 원본 API(`apis.data.go.kr/1741000/household_waste_info/info`)로 주입한다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 자세한 사용법은 [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)를 본다.
`library-book-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA4LIBRARY_AUTH_KEY` 는 불필요하고, self-host proxy 운영자만 프록시 서버 환경변수로 설정한다. 자세한 사용법은 [도서관 도서 조회 가이드](features/library-book-search.md)를 본다.
### `korean-stock-search` proxy quickstart
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
@ -344,6 +348,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
- `cheap-gas-nearby`
- `public-restroom-nearby`
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)
- `library-book-search` (hosted proxy에 `DATA4LIBRARY_AUTH_KEY`가 배포된 경우 사용자 시크릿 불필요)
관련 문서:

View file

@ -18,6 +18,7 @@
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
- 한국 부동산 실거래가 조회 스킬 출시
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
@ -35,7 +36,7 @@
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 올라포케 역삼 포케 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 쿠팡 상품 검색 스킬 출시 (retention-corp/coupang_partners 로컬 MCP 호환 레이어 기반)
- 번개장터 검색 스킬 출시
- 중고차 가격 조회 스킬 출시
- 한국어 맞춤법 검사 스킬 출시

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소/식약처 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY`·`DATA4LIBRARY_AUTH_KEY`·`FOODSAFETYKOREA_API_KEY` 등은 서버에 설정되어 있어야 한다).
## Credential resolution order
@ -44,6 +44,8 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
도서관 도서 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA4LIBRARY_AUTH_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `DATA4LIBRARY_AUTH_KEY` 를 사용한다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
@ -80,6 +82,7 @@ bash scripts/check-setup.sh
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host; API 호출 시 `pageNo=1`, `numOfRows=100` 필수) |
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
| 도서관 도서 조회 | 사용자 시크릿 불필요 (프록시에 `DATA4LIBRARY_AUTH_KEY`가 설정된 hosted/self-host 사용) |
| 의약품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
| 식품 안전 체크 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`가 설정된 hosted/self-host 사용) |
@ -100,6 +103,7 @@ bash scripts/check-setup.sh
- [근처 공중화장실 찾기 가이드](features/public-restroom-nearby.md)
- [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
- [도서관 도서 조회 가이드](features/library-book-search.md)
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](features/mfds-food-safety.md)
- [보안/시크릿 정책](security-and-secrets.md)

View file

@ -28,6 +28,7 @@
- `kordoc`: https://github.com/chrisryugj/kordoc
- `pdfjs-dist`: https://www.npmjs.com/package/pdfjs-dist
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- korean-privacy-terms upstream: https://github.com/kimlawtech/korean-privacy-terms (Apache-2.0)
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
- 한국장학재단 학자금 지원구간 산정절차: https://www.kosaf.go.kr/ko/tuition.do?pg=tuition04_09_01&type=tuition
- 한국장학재단 학자금 지원구간 경곗값 확인: https://www.kosaf.go.kr/ko/tuition.do?naviParam=JH%2C01%2C01%2C03&pg=tuition04_09_07
@ -89,8 +90,10 @@
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- hola-poke-yeoksam reference repo: https://github.com/mnspkm/hola-poke-yeoksam-skill
- hola-poke-yeoksam remote MCP endpoint: https://hola-poke-yeoksam-skill.onrender.com/mcp
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
- retention-corp/coupang_partners (Coupang Partners client and local MCP-compatible layer): https://github.com/retention-corp/coupang_partners
- coupang_partners local MCP contract: local://coupang-mcp
- coupang_partners hosted fallback (credentialless, allowlist-gated): https://a.retn.kr/v1/public/assist
- coupang_partners hosted fallback PR (merged): https://github.com/retention-corp/coupang_partners/pull/1
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
- 블루리본 메인: https://www.bluer.co.kr/
@ -135,3 +138,12 @@
- 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/
- Naver Shopping public BFF JSON: `https://ns-portal.shopping.naver.com/api/v2/shopping-paged-slot?query=<검색어>&source=shp_gui` (네이버 쇼핑 가격비교 스킬의 no-key fallback)
- Naver Developers Search API shopping docs: https://developers.naver.com/docs/serviceapi/search/shopping/shopping.md
- 도서관 정보나루 Open API 활용방법: https://www.data4library.kr/apiUtilization
- 도서관 정보나루 도서 검색 endpoint: https://data4library.kr/api/srchBooks
- 도서관 정보나루 도서 상세 endpoint: https://data4library.kr/api/srchDtlList
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist

View file

@ -85,10 +85,14 @@ chmod 0600 ~/.config/k-skill/secrets.env
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
도서관 도서 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA4LIBRARY_AUTH_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `DATA4LIBRARY_AUTH_KEY` 를 사용한다.
생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서 주입/관리하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
학교 급식 식단 조회는 `k-skill-proxy``/v1/neis/school-search`·`/v1/neis/school-meal`을 호출하고, `KEDU_INFO_KEY`는 프록시 서버에만 두므로 사용자 쪽에 둘 필요가 없다.
도서관 도서 조회는 `k-skill-proxy``/v1/data4library/*` 라우트를 호출하고, `DATA4LIBRARY_AUTH_KEY`는 프록시 서버에만 두므로 사용자 쪽에 둘 필요가 없다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
의약품 안전 체크는 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 호출하고, `DATA_GO_KR_API_KEY` 는 프록시 서버에서만 주입/관리하므로 사용자 쪽에 둘 필요가 없다.
@ -113,6 +117,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`는 proxy 서버 주입, 호출 시 `pageNo=1`·`numOfRows=100` 필수)
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 proxy 서버만)
- 도서관 도서 조회: 사용자 시크릿 불필요 (`DATA4LIBRARY_AUTH_KEY`는 proxy 서버만)
- 의약품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`는 proxy 서버만)
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)

View file

@ -0,0 +1,25 @@
# Disclaimer — 법률 면책 고지
## 한국어
본 프로젝트가 생성하는 모든 법률 문서(개인정보처리방침, 이용약관, 동의서 등)는 **참고용 초안**이며, 법률 자문(legal advice)을 구성하지 않습니다.
실제 서비스에 적용하기 전에 반드시 변호사의 검토를 받으시기 바랍니다. 본 소프트웨어의 사용으로 인해 발생하는 법적 책임은 전적으로 사용자에게 있습니다.
### 관련 제재
- 개인정보 처리방침 미공개: 과태료 5천만원 이하 (개인정보보호법 §75)
- 중대한 위반: 과징금 매출액 최대 10% (2026.9.11 시행)
- 표준약관 마크 부정사용: 과태료 5천만원 이하 (약관규제법 §34①1호)
## English
All legal documents generated by this software (including but not limited to privacy policies and terms of service) are **reference drafts only** and do NOT constitute legal advice.
Users are strongly advised to consult with qualified legal counsel before deploying any generated documents in production. The authors and contributors assume **no liability** for any legal consequences arising from the use of this software.
### Relevant Penalties (Korea)
- Failure to publish a privacy policy: Administrative fine up to KRW 50 million (PIPA §75)
- Serious violations: Up to 10% of revenue in fines (effective 2026-09-11)
- Misuse of standard contract marks: Up to KRW 50 million (Act on the Regulation of Terms and Conditions §34(1)(1))

View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kimlawtech (SpeciAI)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,45 @@
korean-privacy-terms
Copyright 2026 kimlawtech (SpeciAI) and contributors
This product includes software developed as a Claude Code Agent Skill
for generating Korean-law-compliant privacy policies and terms of service
for Next.js projects.
Licensed under the Apache License, Version 2.0 (the "License").
You may obtain a copy of the License at:
http://www.apache.org/licenses/LICENSE-2.0
----------------------------------------------------------------------
LEGAL DISCLAIMER
All legal documents generated by this software (privacy policies,
terms of service, consent forms, etc.) are REFERENCE DRAFTS ONLY.
They do NOT constitute legal advice.
Users are STRONGLY ADVISED to consult qualified legal counsel
(licensed attorneys) before deploying any generated documents
in production environments.
The authors and contributors assume NO LIABILITY for any legal
consequences arising from the use of this software.
----------------------------------------------------------------------
법률 면책 고지
본 소프트웨어가 생성하는 모든 법률 문서(개인정보처리방침, 이용약관,
동의서 등)는 참고용 초안이며, 법률 자문(legal advice)을 구성하지 않습니다.
실제 서비스 배포 전에 반드시 자격 있는 변호사의 검토를 받으시기 바랍니다.
본 소프트웨어의 사용으로 인한 법적 책임은 전적으로 사용자에게 있습니다.
----------------------------------------------------------------------
COMMUNITY
SpeciAI - Korean Legal AI Hub
https://discord.gg/qmCbMaER
Maintainer: @kimlawtech (github.com/kimlawtech)

View file

@ -0,0 +1,138 @@
---
name: korean-privacy-terms
description: kimlawtech/korean-privacy-terms (Apache-2.0) 업스트림을 경유해 Next.js 프로젝트에 한국 법령(개인정보보호법·약관규제법·전자상거래법) 기반 개인정보처리방침·이용약관·쿠키 배너·동의 모달을 생성하는 thin wrapper 스킬.
license: Apache-2.0
metadata:
category: legal
locale: ko-KR
phase: v1
---
# Korean Privacy & Terms (thin wrapper)
## What this skill does
[`kimlawtech/korean-privacy-terms`](https://github.com/kimlawtech/korean-privacy-terms) (Apache-2.0) 업스트림을 사용해 한국 법령 기반 **개인정보처리방침·이용약관·쿠키 배너·회원가입 동의 모달** 생성을 수행한다. k-skill 측은 **얇은 wrapper** 만 유지하고, 실제 인터뷰/렌더/설치 로직은 업스트림에 위임한다.
반영 기준은 업스트림이 관리한다:
- 개인정보위원회 처리방침 작성지침 (2025.4.21)
- 개정 개인정보보호법 (2026.3 공포, 2026.9.11 시행, 과징금 매출액 최대 10%)
- 공정거래위원회 전자상거래 표준약관 제10023호
- EU GDPR / ePrivacy (업스트림 v2.0+ 지원)
이 스킬이 생성하는 모든 문서는 **참고용 초안**이며 **법률 자문이 아니다**. 실서비스 배포 전 반드시 **변호사 검토**가 필요하다.
## When to use
- "개인정보처리방침 만들어줘"
- "이용약관 추가해줘"
- "쿠키 배너 붙여줘"
- "회원가입 동의 모달 필요해"
- "행태정보 고지 추가해줘"
- "개인정보 동의 UI 깎아줘"
## When not to use
- 실제 법률 자문 · 소송 전략 · 개별 약관 분쟁 판단이 필요한 경우 → 변호사 상담
- 한국/EU 외 관할(미국 CCPA · 일본 APPI · 중국 PIPL 등) 을 단독으로 다뤄야 하는 경우 (업스트림 로드맵)
- Next.js App Router 가 아닌 환경 (업스트림이 Pages Router 미지원)
## Prerequisites
- 인터넷 연결 (업스트림 clone 용)
- `git` 2.20+
- `bash`
- 대상 프로젝트가 Next.js 13 ~ 16 App Router 기반일 것 (업스트림 제약)
## Install (dual-install)
업스트림을 `~/.claude/skills/korean-privacy-terms/upstream/``~/.agents/skills/korean-privacy-terms/upstream/` 양쪽에 pinned SHA 로 체크아웃한다. 레포 내부에 업스트림 payload 를 커밋하지 않으므로 실사용 전 반드시 이 단계를 거친다.
```bash
bash korean-privacy-terms/scripts/install.sh
```
레포를 로컬에 clone 하지 않고 이미 홈 디렉토리 스킬 번들만 가진 상태에서도 설치할 수 있다 (installer 는 `${BASH_SOURCE[0]}` 로 절대경로를 해석하므로 cwd 에 구애받지 않는다):
```bash
bash ~/.claude/skills/korean-privacy-terms/scripts/install.sh
# 또는
bash ~/.agents/skills/korean-privacy-terms/scripts/install.sh
```
스크립트는 `korean-privacy-terms/scripts/upstream.pin` 에 기록된 40자리 커밋 SHA 만 체크아웃한다. 두 경로 모두에서 `git -C <path> rev-parse HEAD` 가 pin 과 동일해야 설치 성공.
오프라인 환경이나 네트워크 차단 상황에서는 업스트림 clone 이 실패하므로 이 스킬의 생성 흐름을 실행할 수 없다. 스크립트가 명시적인 실패 메시지를 남기고 비정상 종료한다.
## Mandatory interview first
사용자가 "개인정보처리방침 만들어줘" 같은 생성 요청을 하면 **바로 파일을 만들지 말고 먼저 되묻는다.** 업스트림 `scripts/interview.md` Step 0 ~ 9 의 인터뷰 프로토콜을 따라 순차로 질문하되, 최소한 아래 항목을 확인한 뒤 생성 단계로 넘어간다.
권장 첫 질문 묶음:
- 대상 사용자 범위 (한국 사용자만 / 해외 위주 / 양쪽 글로벌) — 관할법 결정
- 운영 주체 소재지 (한국 / 해외)
- 서비스 유형 (SaaS / 쇼핑몰 / 커뮤니티 / 블로그 / 핀테크 / AI 서비스)
- Next.js 버전 (13 ~ 16), App Router 사용 여부, 언어 (`.ts` / `.tsx` / `.js` / `.jsx`)
- Tailwind 버전 (v3 / v4), 번들러 (Turbopack / Webpack)
- shadcn/ui 기설치 여부, 기존 디자인 시스템 (Tailwind 순정 / Chakra / MUI / Mantine 등)
- 출력 언어 (한국어만 / 영문만 / 한영 병기)
- 14세 미만 대상 여부
- 자동화된 결정(AI) · 행태정보(쿠키) · 맞춤형 광고 처리 여부
- 개인정보 보호책임자(CPO) 정보, 사업자 상호·대표자·주소 (사용자가 알려줘야 함)
인터뷰는 특정 에이전트의 전용 UI 컴포넌트 호출(질문 위젯 등) 에 의존하지 않는 agent-neutral 톤으로, 한 번에 1 ~ 2문항씩 진행한다. 법률 공포감(과태료 폭탄 등)을 유발하는 표현은 피하고, "법적으로 필요한 부분이에요" 정도의 담담한 톤을 유지한다. 모르는 항목은 "넘어가도 됩니다" 로 열어 둔다.
## Workflow
1. 사용자가 트리거 문구를 말하면 먼저 위 인터뷰 게이트를 실행한다.
2. `bash korean-privacy-terms/scripts/install.sh` 로 업스트림을 dual-install 한다 (이미 설치돼 있으면 pin 확인만 한다).
3. 업스트림이 제공하는 순서를 따른다: `scripts/interview.md``scripts/render.md``scripts/install.md`.
4. 업스트림은 Next.js 감지 → MDX/shadcn 의존성 설치 → 템플릿 치환 → `src/app/privacy/page.tsx` · `src/app/terms/page.tsx` · `src/components/legal/*` 등 파일 생성 → 법정 필수 11개 항목 검증 → 면책 주석 삽입 을 순차 실행한다.
5. 생성이 끝나면 사용자에게 보고하되, 아래 **Response policy** 의 고정 블록을 반드시 포함한다.
## CLI examples
```bash
cat korean-privacy-terms/scripts/upstream.pin
bash korean-privacy-terms/scripts/install.sh
git -C ~/.claude/skills/korean-privacy-terms/upstream rev-parse HEAD
git -C ~/.agents/skills/korean-privacy-terms/upstream rev-parse HEAD
```
## Response policy
- 생성된 문서는 **참고용 초안**이며 법률 자문이 아니라는 점을 **모든 답변 말미**에 고지한다.
- 실서비스 적용 전 반드시 **변호사 검토**가 필요함을 고정 문구로 출력한다.
- 2026.9.11 시행 개정 개인정보보호법의 §30 법정 항목, 과징금 상향(매출액 10%), 사업주 책임 등이 업스트림 pin 시점 기준으로 반영돼 있음을 알린다. 이후 개정에 대한 최신 반영 여부는 사용자에게 확인 책임이 있다고 명시한다.
- "등" 으로 뭉뚱그리는 표현, 법정 필수 11개 항목 누락, 면책 주석 제거, 사용자 확인 없이 회사명·책임자명을 추측해 채워넣는 행위는 금지된다 (업스트림 규칙).
- 14세 미만 대상 서비스에 성인용 방침을 그대로 적용하지 않는다.
## Done when
- 인터뷰 게이트가 실행되어 최소 jurisdiction · 서비스 유형 · Next.js 버전 · App Router 여부 · 출력 언어 가 확인되었다.
- `scripts/install.sh` 이 업스트림을 dual-install (`~/.claude/skills/korean-privacy-terms/upstream/` + `~/.agents/skills/korean-privacy-terms/upstream/`) 했고, 양쪽 경로 모두 pin 과 동일한 SHA 이다.
- 업스트림 `scripts/interview.md``scripts/render.md``scripts/install.md` 순서로 생성이 끝났다.
- 법정 필수 11개 항목 검증과 면책 주석 삽입이 업스트림 규칙대로 완료되었다.
- 사용자에게 참고용 초안 + 법률 자문 아님 + 변호사 검토 필수 + 2026.9.11 개정 반영 기준이 함께 고지되었다.
## Failure modes
- 오프라인 또는 네트워크 차단: `scripts/install.sh` 가 upstream clone 단계에서 실패한다. 네트워크 확보 후 재실행.
- pin SHA 가 업스트림에서 삭제/force-push 된 경우: 스크립트가 SHA mismatch 로 비정상 종료한다. `scripts/upstream.pin` 을 최신 태그 SHA 로 bump 하고 PR 을 만든다.
- Next.js Pages Router 단독 프로젝트: 업스트림이 실행을 중단한다. 사용자에게 App Router 전환이 선행 조건임을 안내한다.
- 법률 개정 드리프트: 업스트림이 CHANGELOG 로 반영 기준을 관리한다. pin 만 올리지 말고 업스트림 CHANGELOG 를 함께 확인한다.
## Notes
- upstream: https://github.com/kimlawtech/korean-privacy-terms (Apache-2.0)
- upstream pin: [`scripts/upstream.pin`](./scripts/upstream.pin)
- installer: [`scripts/install.sh`](./scripts/install.sh)
- 법률 면책 전문: [`./DISCLAIMER.md`](./DISCLAIMER.md)
- upstream 저자·커뮤니티 attribution: [`./NOTICE`](./NOTICE) (@kimlawtech, SpeciAI)
- Apache-2.0 전문 (업스트림 `LICENSE` verbatim): [`./LICENSE.upstream`](./LICENSE.upstream) — Apache License, Version 2.0 §4(a) ("give any other recipients of the Work or Derivative Works a copy of this License") 준수를 위해 `install.sh` 실행 전에도 레포 트리에 번들해 둔다. 레포 루트의 [`../LICENSE`](../LICENSE) (MIT) 는 k-skill 자체 라이선스이며 이 스킬 상부에 적용되지 않는다.
- 본 스킬은 업스트림 산출물의 재배포에 해당하므로 Apache License, Version 2.0 §4 요건 (LICENSE 번들, NOTICE 포함, 라이선스 고지) 을 준수한다.
- 중첩 `SKILL.md` 안내: `install.sh` 실행 후 `~/.claude/skills/korean-privacy-terms/upstream/SKILL.md` 가 존재하게 되지만, Claude Code / Codex / Vercel Agent Skills 등 에이전트 플랫폼은 top-level `SKILL.md` 만 discovery 대상으로 삼는다. 중첩 업스트림 `SKILL.md` 는 직접 호출되지 않는다.

View file

@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# korean-privacy-terms upstream installer.
#
# k-skill 측은 얇은 wrapper 만 유지하고, 인터뷰/렌더/설치 로직은 업스트림
# kimlawtech/korean-privacy-terms (Apache-2.0) 에 위임한다. 이 스크립트는
# scripts/upstream.pin 에 기록된 커밋 SHA 를 두 홈 디렉토리 스킬 경로
#
# ~/.claude/skills/korean-privacy-terms/upstream/
# ~/.agents/skills/korean-privacy-terms/upstream/
#
# 아래에 동일하게 체크아웃해 둔다. 레포 내부에 업스트림을 커밋하지 않으므로
# 실사용 전에는 반드시 이 스크립트를 한 번 실행해야 한다.
#
# 사용법:
# bash korean-privacy-terms/scripts/install.sh
#
# AGENTS.md 규칙에 따라 ~/.claude/skills 와 ~/.agents/skills 는 홈 디렉토리 구조
# 의 indirection(예: ~/.agents/skills 가 symlink 인 경우) 을 존중한다. 레포
# 내부에 repo-local .claude/ 또는 .agents/ 디렉토리는 생성하지 않는다.
set -euo pipefail
UPSTREAM_REPO="https://github.com/kimlawtech/korean-privacy-terms.git"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PIN_FILE="${SCRIPT_DIR}/upstream.pin"
SKILL_NAME="korean-privacy-terms"
if [[ ! -f "${PIN_FILE}" ]]; then
echo "[korean-privacy-terms] upstream.pin not found at ${PIN_FILE}" >&2
exit 1
fi
UPSTREAM_SHA="$(tr -d '[:space:]' <"${PIN_FILE}")"
if [[ ! "${UPSTREAM_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "[korean-privacy-terms] upstream.pin must contain a 40-char git SHA (got: ${UPSTREAM_SHA})" >&2
exit 1
fi
CACHE_DIR="${HOME}/.cache/k-skill/${SKILL_NAME}"
CLONE_DIR="${CACHE_DIR}/upstream"
mkdir -p "${CACHE_DIR}"
if [[ ! -d "${CLONE_DIR}/.git" ]]; then
echo "[korean-privacy-terms] cloning upstream into ${CLONE_DIR}"
if ! git clone --filter=blob:none "${UPSTREAM_REPO}" "${CLONE_DIR}" >&2; then
echo "" >&2
echo "[korean-privacy-terms] upstream clone failed (network required)." >&2
echo " upstream: ${UPSTREAM_REPO}" >&2
echo " 오프라인 환경에서는 이 스킬의 생성 흐름을 실행할 수 없다." >&2
echo " 네트워크를 확보한 뒤 다시 실행해달라." >&2
exit 1
fi
fi
echo "[korean-privacy-terms] syncing upstream to pinned SHA ${UPSTREAM_SHA}"
git -C "${CLONE_DIR}" fetch --tags origin "${UPSTREAM_SHA}" >&2 || git -C "${CLONE_DIR}" fetch origin >&2
git -C "${CLONE_DIR}" checkout --force --detach "${UPSTREAM_SHA}" >&2
HEAD_SHA="$(git -C "${CLONE_DIR}" rev-parse HEAD)"
if [[ "${HEAD_SHA}" != "${UPSTREAM_SHA}" ]]; then
echo "[korean-privacy-terms] HEAD (${HEAD_SHA}) does not match pinned SHA (${UPSTREAM_SHA})" >&2
exit 1
fi
# Dual-install targets. Both paths respect AGENTS.md indirection rules and do not
# introduce repo-local skill directories.
HOME_DIRS=(
"${HOME}/.claude/skills/${SKILL_NAME}"
"${HOME}/.agents/skills/${SKILL_NAME}"
)
for HOME_SKILL_DIR in "${HOME_DIRS[@]}"; do
HOME_UPSTREAM="${HOME_SKILL_DIR}/upstream"
mkdir -p "${HOME_SKILL_DIR}"
if [[ -e "${HOME_UPSTREAM}" || -L "${HOME_UPSTREAM}" ]]; then
rm -rf "${HOME_UPSTREAM}"
fi
# rsync-style copy preserves the git metadata so `git -C <path> rev-parse HEAD`
# still reports the pinned SHA after install. Prefer rsync when available;
# otherwise fall back to `cp -a` which is portable on macOS and Linux.
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "${CLONE_DIR}/" "${HOME_UPSTREAM}/"
else
cp -a "${CLONE_DIR}/" "${HOME_UPSTREAM}/"
fi
INSTALLED_SHA="$(git -C "${HOME_UPSTREAM}" rev-parse HEAD)"
if [[ "${INSTALLED_SHA}" != "${UPSTREAM_SHA}" ]]; then
echo "[korean-privacy-terms] ${HOME_UPSTREAM} HEAD (${INSTALLED_SHA}) does not match pin (${UPSTREAM_SHA})" >&2
exit 1
fi
echo "[korean-privacy-terms] installed upstream@${UPSTREAM_SHA} -> ${HOME_UPSTREAM}"
done
echo ""
echo "[korean-privacy-terms] done."
echo " pinned upstream SHA: ${UPSTREAM_SHA}"
echo " upstream repo: ${UPSTREAM_REPO}"
echo " 이 스킬이 생성하는 모든 문서는 참고용 초안이며 법률 자문이 아니다."
echo " 실서비스 배포 전 반드시 변호사 검토를 받아달라."

View file

@ -0,0 +1 @@
e390f7b9feb825e368c26726363ea5ce11a34083

View file

@ -0,0 +1,149 @@
---
name: library-book-search
description: Use when the user asks to search Korean library books, book details, library holdings, or whether a Korean public library owns a book via k-skill-proxy Data4Library routes.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Library Book Search (Data4Library)
## What this skill does
국립중앙도서관의 **도서관 정보나루(Data4Library)** Open API를 `k-skill-proxy`가 중계하는 HTTP API로 조회한다.
- 키워드로 도서를 검색한다.
- ISBN으로 상세 서지·대출정보를 확인한다.
- ISBN + 지역코드로 해당 도서를 소장한 도서관 목록을 찾는다.
- 도서관 코드 + ISBN으로 특정 도서관의 소장/대출 가능 여부를 확인한다.
## When to use
- "역사 관련 책 도서관 정보나루에서 찾아줘"
- "이 ISBN 소장한 서울 도서관 찾아줘"
- "정독도서관에 이 책 있는지 확인해줘"
- "도서관 도서 조회 가능해?"
## Prerequisites
- 인터넷 연결
- `curl` 사용 가능 환경
- `k-skill-proxy``DATA4LIBRARY_AUTH_KEY`가 설정된 배포(기본 hosted 또는 self-host)에 접근 가능할 것
## Credential requirements
사용자에게 필요한 시크릿은 없으며, `DATA4LIBRARY_AUTH_KEY`는 프록시 서버에서만 관리한다.
- 사용자 측 **필수 시크릿 없음**.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `DATA4LIBRARY_AUTH_KEY`**프록시 서버** 환경에만 둔다. 사용자에게 인증키를 요구하거나 응답에 노출하지 않는다.
## Proxy base URL
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
BASE="${BASE%/}"
```
## Workflow
### 1) Collect the intent and minimal inputs
사용자 요청에 따라 필요한 값만 묻는다.
- 키워드 도서 검색: `keyword`만 있으면 시작한다.
- 상세 조회: ISBN(10자리 또는 13자리)이 필요하다.
- 소장 도서관 조회: ISBN + 광역 지역코드(`region`)가 필요하다. 시군구 상세코드(`dtl_region`)가 있으면 같이 쓴다.
- 특정 도서관 소장 확인: 도서관 코드(`libraryCode`) + ISBN이 필요하다.
도서관 코드나 지역코드가 없으면 먼저 `library-search`로 후보를 보거나, 사용자에게 알고 있는 도서관명·지역을 더 물어본다. 이름만으로 확정되지 않으면 임의로 하나를 고르지 않는다.
### 2) Search books (`/v1/data4library/book-search`)
```bash
curl -fsS --get "${BASE}/v1/data4library/book-search" \
--data-urlencode "keyword=역사" \
--data-urlencode "pageNo=1" \
--data-urlencode "pageSize=10"
```
별칭: `q`, `query`, `page`, `limit`도 허용된다.
응답의 `response.docs[].doc`에서 주로 볼 필드:
- `bookname` — 도서명
- `authors` — 저자
- `publisher` — 출판사
- `publication_year` — 출판연도
- `isbn13` — 상세/소장 조회에 쓸 ISBN
### 3) Fetch book detail (`/v1/data4library/book-detail`)
```bash
curl -fsS --get "${BASE}/v1/data4library/book-detail" \
--data-urlencode "isbn13=9788971998557" \
--data-urlencode "loaninfoYN=Y"
```
- `loaninfoYN=Y`를 주면 upstream이 제공하는 인기 대출 지역·연령·대출건수 같은 추가 정보를 함께 요청한다.
- 상세 응답이 비어 있으면 ISBN 오타, 미수집 도서, upstream 지연 가능성을 안내한다.
### 4) Find libraries that hold a book (`/v1/data4library/libraries-by-book`)
```bash
curl -fsS --get "${BASE}/v1/data4library/libraries-by-book" \
--data-urlencode "isbn=9788971998557" \
--data-urlencode "region=11" \
--data-urlencode "pageNo=1" \
--data-urlencode "pageSize=10"
```
- `region`은 도서관 정보나루 지역코드다. 예: 서울특별시 코드가 필요한 경우 `11`처럼 숫자 코드를 사용한다.
- `dtl_region`이 있으면 더 좁힌다.
- 결과가 여러 개면 도서관명·주소·홈페이지·전화번호(응답에 있는 경우)를 요약해 보여 주고, 실시간 대출가능 여부는 별도 `book-exists`로 확인한다.
### 5) Check one library's holding (`/v1/data4library/book-exists`)
```bash
curl -fsS --get "${BASE}/v1/data4library/book-exists" \
--data-urlencode "libraryCode=111001" \
--data-urlencode "isbn13=9788971998557"
```
- `hasBook`, `loanAvailable` 등 upstream 응답 필드를 그대로 해석한다.
- 대출 가능 여부는 도서관 시스템 동기화 지연이 있을 수 있으므로, 최종 방문 전 도서관 홈페이지/전화 확인을 권한다.
### 6) Optional: library list (`/v1/data4library/library-search`)
```bash
curl -fsS --get "${BASE}/v1/data4library/library-search" \
--data-urlencode "region=11" \
--data-urlencode "pageNo=1" \
--data-urlencode "pageSize=10"
```
## Summarize for the user
- 도서명, 저자, 출판사, 출판연도, ISBN을 먼저 정리한다.
- 소장 도서관 목록은 사용자의 지역·이동 가능성을 기준으로 상위 후보만 보여 준다.
- JSON 전체를 길게 붙이지 말고, 필요한 필드와 다음 단계(상세 조회/소장 확인/도서관 선택)를 제안한다.
## Upstream reference
- 도서관 정보나루 Open API 활용방법: `https://www.data4library.kr/apiUtilization`
## Failure modes
- 프록시에 `DATA4LIBRARY_AUTH_KEY` 미설정 → `503` / `upstream_not_configured`
- 키워드·ISBN·지역코드·도서관 코드 누락 → `400` / `bad_request`
- ISBN 자리수 오류 → 하이픈을 제외한 ISBN-10(마지막 X 허용) 또는 ISBN-13으로 다시 요청
- 도서관 코드/지역코드 불명확 — 사용자에게 후보를 보여 주고 선택 받기
- Data4Library API 일시 장애·호출 제한·데이터 미수집
## Notes
- 기본 posture는 read-only 조회다. 예약, 대출, 개인정보 기반 도서관 로그인 자동화는 하지 않는다.
- `DATA4LIBRARY_AUTH_KEY`는 프록시 서버 전용이다. 사용자 측에는 시크릿이 없다.
- 프록시가 upstream `authKey``format=json`을 주입하므로, 사용자가 넘긴 `authKey`/`format`은 신뢰하지 않는다.

View file

@ -0,0 +1,104 @@
---
name: naver-shopping-search
description: 네이버 쇼핑 공개 BFF JSON을 k-skill-proxy로 조회해 상품 후보, 최저가, 판매처 링크를 보수적으로 가격비교한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Naver Shopping Search
## What this skill does
`k-skill-proxy`가 네이버 검색 Open API 쇼핑 검색(`shop.json`)을 우선 사용하고, 키가 없을 때만 네이버 쇼핑/검색의 로그인 없는 공개 BFF JSON endpoint를 **단일 검색 요청**으로 가져와 상품 후보를 정규화한다.
- 상품명/검색어로 네이버 쇼핑 후보를 찾는다.
- 현재 노출 가격, 판매처, 링크, 이미지, 리뷰/구매 수(노출될 때만)를 정리한다.
- 가격이 낮은 후보와 공식몰/브랜드몰 후보를 분리해서 비교할 수 있다.
- 주문, 장바구니, 찜, 로그인 세션 접근은 하지 않는다.
## When to use
- "네이버 쇼핑에서 에어팟 가격 비교해줘"
- "네이버 최저가로 커피머신 찾아줘"
- "네이버 쇼핑 링크랑 판매처별 가격을 비교해줘"
- "이 상품 네이버 쇼핑에서 얼마쯤 해?"
## When not to use
- 회원 전용가, 쿠폰 적용가, 네이버페이 개인화 혜택을 확정해야 하는 경우
- 주문/장바구니/찜/로그인이 필요한 액션
- 차단 우회, CAPTCHA 우회, fingerprint spoofing 등 접근 통제를 우회해야 하는 경우
## Required inputs
상품명 또는 검색어가 없으면 먼저 물어본다.
권장 질문:
> 찾을 네이버 쇼핑 상품명이나 검색어를 알려주세요. 예: 에어팟 프로 2세대, 아이폰 15 케이스
검색어가 너무 넓으면 브랜드/용량/모델명을 추가로 물어본다.
## Proxy endpoint
기본값은 public/read-only/no-auth 프록시다. 프록시 서버에 `NAVER_SEARCH_CLIENT_ID``NAVER_SEARCH_CLIENT_SECRET`이 있으면 공식 Search API를 우선 사용한다.
```bash
curl -fsS --get "${KSKILL_PROXY_BASE_URL:-http://127.0.0.1:4020}/v1/naver-shopping/search" \
--data-urlencode 'q=에어팟 프로 2세대' \
--data-urlencode 'limit=10' \
--data-urlencode 'sort=rel'
```
쿼리 파라미터:
- `q` 또는 `query` — 검색어. 2글자 이상.
- `limit` — 반환 개수. 기본 10, 최대 40으로 clamp.
- `page` — 페이지. 기본 1. no-key BFF fallback에서는 BFF의 `page`를 요청하고 해당 페이지 카드만 정규화한다.
- `sort``rel`, `date`, `price_asc`, `price_dsc`, `review` 중 하나. 알 수 없는 값은 `rel`.
- 공식 Search API 경로는 네이버 API sort를 사용한다. 단, 공식 API가 `review` 정렬을 지원하지 않아 `review` 요청은 upstream `sort=sim`으로 조회하고 `meta.sort_applied: "unsupported"`, `meta.upstream_sort: "sim"`으로 표시한다.
- no-key BFF fallback은 `rel`은 BFF 노출 순서를 유지하고, `price_asc`/`price_dsc`/`review`는 선택된 BFF 페이지 카드 안에서 로컬 정렬한다. BFF 카드에 날짜 필드가 없어 `date``meta.sort_applied: "unsupported"`로 표시하고 BFF 노출 순서를 유지한다.
응답 주요 필드:
- `items[].title`
- `items[].price` / `items[].price_text`
- `items[].mall_name`
- `items[].url`
- `items[].image_url`
- `items[].review_count`, `purchase_count`, `score` (노출될 때만)
- `meta.extraction``naver-openapi`, `bff-json`, `embedded-json`, `html-card`, `none`
- `meta.sort_applied``upstream`, `local`, `unsupported` 중 하나
## Workflow
1. 검색어를 확인한다.
2. `GET /v1/naver-shopping/search` 를 호출한다.
3. `items`가 있으면 요청 sort와 `meta.sort_applied`를 확인한 뒤 가격 낮은 순, 공식/브랜드몰 여부, 리뷰 수 등을 기준으로 3~5개 후보를 짧게 비교한다.
4. `meta.extraction`과 조회 시각 기준임을 함께 말한다.
5. `items`가 비었거나 upstream 차단/오류가 나면 우회 시도를 반복하지 말고, 검색어를 좁히거나 브라우저 수동 확인을 안내한다.
## Response style
- 가격은 "조회 시점 네이버 쇼핑 노출가"라고 표현한다.
- 배송비, 쿠폰, 옵션 추가금, 회원 혜택은 비로그인 공개 검색만으로 확정하지 않는다.
- 판매처 신뢰도는 노출된 판매처명/리뷰 수만 근거로 보수적으로 말한다.
- 최저가만 단정하지 말고 동률/옵션 차이를 조심한다.
## Failure modes
- 공식 Search API 키가 없어서 BFF fallback을 사용할 때는 네이버가 특정 IP/환경에 418/403 등 bot-block 응답을 줄 수 있다.
- no-key fallback은 `ns-portal.shopping.naver.com/api/v2/shopping-paged-slot?query=<검색어>&source=shp_gui` 공개 JSON path를 사용한다.
- 검색 결과 BFF JSON 스키마는 비공식 프론트엔드 표면이라 바뀔 수 있다.
- 가격/품절/배송 정보는 실시간으로 바뀐다.
- 프록시는 접근 통제 우회를 하지 않는다. 공식 Search API 또는 단일 공개 검색 요청 + 캐시 + rate limit만 사용한다.
## Done when
- 검색어를 확인했다.
- 네이버 쇼핑 후보를 최소 1개 이상 반환하거나, 왜 반환하지 못했는지 설명했다.
- 가격/판매처/링크를 조회 시점 기준으로 보수적으로 정리했다.
- 로그인/주문/차단 우회 범위를 벗어나지 않았다.

15
package-lock.json generated
View file

@ -1172,6 +1172,10 @@
"quansync": "^0.2.7"
}
},
"node_modules/parking-lot-search": {
"resolved": "packages/parking-lot-search",
"link": true
},
"node_modules/path-exists": {
"version": "4.0.0",
"dev": true,
@ -1756,7 +1760,7 @@
}
},
"packages/kbl-results": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1783,13 +1787,20 @@
"node": ">=18"
}
},
"packages/public-restroom-nearby": {
"packages/parking-lot-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/public-restroom-nearby": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/toss-securities": {
"version": "0.2.0",
"license": "MIT",

View file

@ -9,11 +9,10 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_kakaotalk_mac && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && 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 market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && 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 market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -10,6 +10,7 @@
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
- `GET /v1/parking-lots/search` — 전국주차장정보표준데이터 기반 근처 공영주차장 검색(`DATA_GO_KR_API_KEY`)
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `GET /v1/mfds/drug-safety/lookup` — 식약처 의약품개요정보(e약은요) + 안전상비의약품 정보(`DATA_GO_KR_API_KEY`)
@ -17,6 +18,12 @@
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-search` — 도서관 정보나루 도서 검색(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` — 도서 소장 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` — 도서관별 도서 소장여부(`DATA4LIBRARY_AUTH_KEY`)
## 환경변수
@ -25,14 +32,16 @@
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 선택: 네이버 검색 Open API 쇼핑 검색(`shop.json`) 키. 설정되면 네이버 쇼핑 route가 bot-block 위험이 낮은 공식 API를 우선 사용하고, 없으면 공개 BFF JSON(`ns-portal.shopping.naver.com/api/v2/shopping-paged-slot`) 파서로 fallback. 공식 API는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key fallback은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -94,6 +103,18 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
--data-urlencode 'numOfRows=100'
```
공영주차장 검색 예시 (`DATA_GO_KR_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/parking-lots/search' \
--data-urlencode 'latitude=37.573713' \
--data-urlencode 'longitude=126.978338' \
--data-urlencode 'address_hint=서울특별시 종로구' \
--data-urlencode 'limit=3' \
--data-urlencode 'radius=1500'
```
의약품 안전 체크 예시 (`DATA_GO_KR_API_KEY` 필요):
```bash
@ -111,6 +132,37 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/food-safety/search' \
--data-urlencode 'limit=5'
```
네이버 쇼핑 가격비교 예시 (`NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET`이 있으면 공식 Search API를 우선 사용):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/naver-shopping/search' \
--data-urlencode 'q=에어팟 프로 2세대' \
--data-urlencode 'limit=10'
```
도서관 정보나루 도서 검색 예시 (`DATA4LIBRARY_AUTH_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/data4library/book-search' \
--data-urlencode 'keyword=역사' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'pageSize=10'
```
도서관 정보나루 상세/소장 확인 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/data4library/book-detail' \
--data-urlencode 'isbn13=9788971998557' \
--data-urlencode 'loaninfoYN=Y'
curl -fsS --get 'http://127.0.0.1:4020/v1/data4library/book-exists' \
--data-urlencode 'libraryCode=111001' \
--data-urlencode 'isbn13=9788971998557'
```
한국 주식 검색 예시:
```bash

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/mfds.js && node --check src/molit.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-shopping.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/molit.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -147,13 +147,44 @@ async function requestJson(url, { params, fetchImpl = global.fetch } = {}) {
}
function extractDataGoItems(payload) {
const raw = payload?.body?.items?.item;
if (Array.isArray(raw)) {
return raw.filter((item) => item && typeof item === "object");
const items = payload?.body?.items;
if (!items) {
return [];
}
if (raw && typeof raw === "object") {
return [raw];
// Shape A (legacy XML→JSON autoconvert): body.items = { item: [...] | {...} }
if (!Array.isArray(items) && typeof items === "object") {
const inner = items.item;
if (Array.isArray(inner)) {
return inner.filter((entry) => entry && typeof entry === "object");
}
if (inner && typeof inner === "object") {
return [inner];
}
return [];
}
// Shape B (native JSON): body.items = [...] flat array of entries.
// Shape C (wrapped JSON): body.items = [{ item: {...} }, ...] where each entry has a single `item` key.
if (Array.isArray(items)) {
return items
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
if (
entry.item &&
typeof entry.item === "object" &&
!Array.isArray(entry.item) &&
Object.keys(entry).length === 1
) {
return entry.item;
}
return entry;
})
.filter((entry) => entry && typeof entry === "object");
}
return [];
}

View file

@ -0,0 +1,955 @@
const NAVER_SHOPPING_BASE_URL = "https://search.shopping.naver.com";
const NAVER_SHOPPING_BFF_BASE_URL = "https://ns-portal.shopping.naver.com";
const NAVER_SHOPPING_SEARCH_PATH = "/api/v2/shopping-paged-slot";
const NAVER_SHOPPING_OPEN_API_URL = "https://openapi.naver.com/v1/search/shop.json";
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 40;
const ALLOWED_SORTS = new Set(["rel", "date", "price_asc", "price_dsc", "review"]);
const LOCALLY_SORTABLE_BFF_SORTS = new Set(["price_asc", "price_dsc", "review"]);
function parseInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function decodeHtmlEntities(value) {
if (value === undefined || value === null) {
return "";
}
return String(value)
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 10)))
.replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)));
}
function stripTags(value) {
return decodeHtmlEntities(value)
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function getFirstValue(object, keys) {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(object, key)) {
const value = object[key];
if (value !== undefined && value !== null && value !== "") {
return value;
}
}
}
return null;
}
function parseDigits(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.trunc(value);
}
const text = stripTags(value);
if (!text) {
return null;
}
const digits = text.replace(/[^0-9]/g, "");
if (!digits) {
return null;
}
const parsed = Number.parseInt(digits, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function parseFloatNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
const text = stripTags(value);
if (!text) {
return null;
}
const match = text.match(/\d+(?:\.\d+)?/);
if (!match) {
return null;
}
const parsed = Number.parseFloat(match[0]);
return Number.isFinite(parsed) ? parsed : null;
}
function formatWon(value) {
return `${new Intl.NumberFormat("ko-KR").format(value)}`;
}
function normalizeUrl(value, baseUrl = NAVER_SHOPPING_BASE_URL) {
const raw = trimOrNull(stripTags(value));
if (!raw || /^javascript:/i.test(raw)) {
return null;
}
if (raw.startsWith("//")) {
return `https:${raw}`;
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
if (raw.startsWith("/")) {
return `${baseUrl}${raw}`;
}
return null;
}
function firstUrlFromObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
for (const key of ["pcUrl", "mobileUrl", "url", "link", "href"]) {
const normalized = normalizeUrl(value[key]);
if (normalized) {
return normalized;
}
}
return null;
}
function normalizeUrlCandidate(value) {
if (Array.isArray(value)) {
for (const entry of value) {
const normalized = normalizeUrlCandidate(entry);
if (normalized) {
return normalized;
}
}
return null;
}
if (value && typeof value === "object") {
const objectUrl = firstUrlFromObject(value);
if (objectUrl) {
return objectUrl;
}
for (const key of ["imageUrl", "image", "thumbnailUrl", "src"]) {
const normalized = normalizeUrl(value[key]);
if (normalized) {
return normalized;
}
}
return null;
}
return normalizeUrl(value);
}
function normalizeString(value) {
const normalized = stripTags(value);
return normalized || null;
}
function collectCategory(candidate) {
const category = [];
for (const key of ["category1Name", "category2Name", "category3Name", "category4Name", "categoryName", "category"]) {
const value = normalizeString(candidate[key]);
if (value && !category.includes(value)) {
category.push(value);
}
}
return category;
}
function normalizeProductCandidate(candidate, { rank, source }) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return null;
}
const title = normalizeString(getFirstValue(candidate, [
"productName",
"productTitle",
"product_name",
"productTitleOrg",
"mallProductName",
"title",
"name"
]));
const price = parseDigits(getFirstValue(candidate, [
"lowPrice",
"price",
"lprice",
"salePrice",
"discountedPrice",
"mobileLowPrice",
"minPrice",
"priceValue"
]));
if (!title || price === null) {
return null;
}
const mallName = normalizeString(getFirstValue(candidate, [
"mallName",
"mallNm",
"mall_name",
"storeName",
"sellerName",
"channelName",
"adMallName"
]));
const url = normalizeUrl(getFirstValue(candidate, [
"productUrl",
"mallProductUrl",
"crUrl",
"adcrUrl",
"url",
"link"
]));
const imageUrl = normalizeUrl(getFirstValue(candidate, [
"imageUrl",
"imgUrl",
"image",
"thumbnail",
"thumbnailUrl",
"imgSrc"
]));
const productId = normalizeString(getFirstValue(candidate, ["productId", "id", "nvMid", "catalogId"]));
const reviewCount = parseDigits(getFirstValue(candidate, ["reviewCount", "reviewCnt", "review_count"]));
const purchaseCount = parseDigits(getFirstValue(candidate, ["purchaseCnt", "purchaseCount", "purchase_count"]));
const score = parseFloatNumber(getFirstValue(candidate, ["scoreInfo", "score", "rating", "reviewScore"]));
const category = collectCategory(candidate);
const adFlag = getFirstValue(candidate, ["isAd", "ad", "adId", "adcrUrl", "adProduct"]);
return {
rank,
product_id: productId,
title,
price,
price_text: formatWon(price),
mall_name: mallName,
url,
image_url: imageUrl,
category,
review_count: reviewCount,
purchase_count: purchaseCount,
score,
is_ad: Boolean(adFlag),
source
};
}
function normalizeBffProductCandidate(candidate, { rank, source }) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
return null;
}
const title = normalizeString(getFirstValue(candidate, [
"productNameOrg",
"productName",
"productTitle",
"product_name",
"mallProductName",
"title",
"name"
]));
const discountedPrice = parseDigits(getFirstValue(candidate, [
"discountedSalePrice",
"discountedKRWSalePrice",
"couponDiscountedPrice",
"discountedPrice",
"mobileLowPrice",
"lowPrice",
"price"
]));
const salePrice = parseDigits(getFirstValue(candidate, [
"salePrice",
"saleKRWPrice",
"originPrice",
"originalPrice",
"hprice"
]));
const price = discountedPrice ?? salePrice;
if (!title || price === null) {
return null;
}
const highPrice = salePrice !== null && salePrice > price ? salePrice : parseDigits(candidate.highPrice);
const imageUrl = normalizeUrlCandidate(candidate.images)
|| normalizeUrlCandidate(getFirstValue(candidate, ["imageUrl", "imgUrl", "image", "thumbnail", "thumbnailUrl", "imgSrc"]));
const url = normalizeUrlCandidate(getFirstValue(candidate, ["productUrl", "mallProductUrl", "productClickUrl", "crUrl", "adcrUrl", "url", "link"]));
const cardType = normalizeString(candidate.cardType);
const sourceType = normalizeString(candidate.sourceType);
const adFlag = getFirstValue(candidate, ["isAd", "ad", "adId", "adcrUrl", "adProduct"]);
const isAd = Boolean(adFlag)
|| /ad/i.test(cardType || "")
|| /ad/i.test(sourceType || "");
return compactProduct({
rank,
product_id: normalizeString(getFirstValue(candidate, ["nvMid", "productId", "id", "catalogId", "channelProductId"])),
title,
price,
high_price: highPrice,
price_text: formatWon(price),
mall_name: normalizeString(getFirstValue(candidate, ["mallName", "mallNm", "mall_name", "storeName", "sellerName", "channelName", "adMallName"])),
url,
image_url: imageUrl,
category: collectCategory(candidate),
review_count: parseDigits(getFirstValue(candidate, ["totalReviewCount", "reviewCount", "reviewCnt", "review_count"])),
purchase_count: parseDigits(getFirstValue(candidate, ["purchaseCount", "purchaseCnt", "purchase_count"])),
score: parseFloatNumber(getFirstValue(candidate, ["averageReviewScore", "scoreInfo", "score", "rating", "reviewScore"])),
is_ad: isAd,
is_brand_store: candidate.isBrandStore === true ? true : undefined,
product_type: normalizeString(candidate.productType),
source
});
}
function compactProduct(product) {
return Object.fromEntries(
Object.entries(product).filter(([, value]) => {
if (value === null || value === undefined) {
return false;
}
if (Array.isArray(value) && value.length === 0) {
return false;
}
return true;
})
);
}
function productDedupKey(product) {
if (product.product_id) {
return `id:${product.product_id}`;
}
return [product.title, product.price, product.mall_name || "", product.url || ""].join("|").toLowerCase();
}
function compareWithMissingLast(left, right, direction = "asc") {
const leftMissing = left === undefined || left === null;
const rightMissing = right === undefined || right === null;
if (leftMissing && rightMissing) {
return 0;
}
if (leftMissing) {
return 1;
}
if (rightMissing) {
return -1;
}
return direction === "desc" ? right - left : left - right;
}
function applyLocalBffSort(items, sort) {
if (!LOCALLY_SORTABLE_BFF_SORTS.has(sort)) {
return items;
}
return [...items].sort((left, right) => {
if (sort === "price_asc") {
return compareWithMissingLast(left.price, right.price, "asc")
|| compareWithMissingLast(left.review_count, right.review_count, "desc")
|| left.rank - right.rank;
}
if (sort === "price_dsc") {
return compareWithMissingLast(left.price, right.price, "desc")
|| compareWithMissingLast(left.review_count, right.review_count, "desc")
|| left.rank - right.rank;
}
if (sort === "review") {
return compareWithMissingLast(left.review_count, right.review_count, "desc")
|| compareWithMissingLast(left.purchase_count, right.purchase_count, "desc")
|| compareWithMissingLast(left.score, right.score, "desc")
|| compareWithMissingLast(left.price, right.price, "asc")
|| left.rank - right.rank;
}
return left.rank - right.rank;
});
}
function getBffPageNumber(page) {
return parseDigits(page?.page ?? page?.pageNo ?? page?.pageNumber ?? page?.pagingIndex);
}
function selectBffPages(payload, requestedPage) {
const pages = Array.isArray(payload?.data) ? payload.data : [];
if (pages.length === 0) {
return [];
}
const numberedPages = pages.filter((page) => getBffPageNumber(page) === requestedPage);
if (numberedPages.length > 0) {
return numberedPages;
}
if (requestedPage === 1) {
return pages.filter((page) => getBffPageNumber(page) === null).length > 0
? pages.filter((page) => getBffPageNumber(page) === null)
: pages.slice(0, 1);
}
return [];
}
function getBffSortApplied(sort) {
if (sort === "rel") {
return "upstream";
}
if (LOCALLY_SORTABLE_BFF_SORTS.has(sort)) {
return "local";
}
return "unsupported";
}
function extractScriptBodies(html) {
return [...String(html).matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)]
.map((match) => decodeHtmlEntities(match[1]).trim())
.filter(Boolean);
}
function parseJsonScriptPayloads(html) {
const payloads = [];
const nextDataMatch = String(html).match(/<script\b[^>]*id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i);
if (nextDataMatch) {
try {
payloads.push(JSON.parse(decodeHtmlEntities(nextDataMatch[1])));
} catch {
// Continue with other script candidates.
}
}
for (const body of extractScriptBodies(html)) {
if (!/(product|mall|lowPrice|price|shopping)/i.test(body)) {
continue;
}
const trimmed = body.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
payloads.push(JSON.parse(trimmed));
continue;
} catch {
// Try balanced object extraction below.
}
}
const extracted = extractFirstBalancedJson(trimmed);
if (extracted) {
try {
payloads.push(JSON.parse(extracted));
} catch {
// Ignore non-JSON scripts.
}
}
}
return payloads;
}
function extractFirstBalancedJson(text) {
const start = text.indexOf("{");
if (start < 0) {
return null;
}
let depth = 0;
let inString = false;
let quote = null;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
inString = true;
quote = char;
continue;
}
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0) {
return text.slice(start, index + 1);
}
}
}
return null;
}
function collectProductsFromJson(root, limit) {
const items = [];
const seen = new Set();
function addCandidate(candidate) {
const normalized = normalizeProductCandidate(candidate, {
rank: items.length + 1,
source: "embedded-json"
});
if (!normalized) {
return;
}
const compacted = compactProduct(normalized);
const key = productDedupKey(compacted);
if (seen.has(key)) {
return;
}
seen.add(key);
items.push(compacted);
}
function visit(value, depth = 0) {
if (items.length >= limit || depth > 18 || value === null || value === undefined) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
visit(entry, depth + 1);
if (items.length >= limit) {
return;
}
}
return;
}
if (typeof value !== "object") {
return;
}
addCandidate(value);
for (const key of ["item", "product", "catalog", "adProduct"]) {
if (value[key] && typeof value[key] === "object") {
addCandidate(value[key]);
}
}
for (const child of Object.values(value)) {
visit(child, depth + 1);
if (items.length >= limit) {
return;
}
}
}
visit(root);
return items.slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
}
function parseAttribute(tag, name) {
const match = tag.match(new RegExp(`${name}=["']([^"']+)["']`, "i"));
return match ? decodeHtmlEntities(match[1]) : null;
}
function extractMallName(fragment) {
const mallMatch = fragment.match(/<[^>]*(?:class|data-testid)=["'][^"']*(?:mall|store|seller)[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i);
return mallMatch ? normalizeString(mallMatch[1]) : null;
}
function extractImageUrl(fragment) {
const imageMatch = fragment.match(/<img\b[^>]*(?:src|data-src)=["']([^"']+)["'][^>]*>/i);
return imageMatch ? normalizeUrl(imageMatch[1]) : null;
}
function parseHtmlCards(html, limit) {
const items = [];
const seen = new Set();
const text = String(html);
const anchorRegex = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
let match;
while ((match = anchorRegex.exec(text)) && items.length < limit) {
const href = parseAttribute(match[1], "href");
const url = normalizeUrl(href);
if (!url || !/(?:shopping\.naver\.com|smartstore\.naver\.com|brand\.naver\.com|naver\.com\/)/i.test(url)) {
continue;
}
const title = normalizeString(match[2]);
if (!title || title.length < 2) {
continue;
}
const fragment = text.slice(match.index, Math.min(text.length, match.index + 1800));
const priceMatch = fragment.match(/([0-9][0-9,\.]{2,})\s*원/);
const price = priceMatch ? parseDigits(priceMatch[1]) : null;
if (price === null) {
continue;
}
const product = compactProduct({
rank: items.length + 1,
title,
price,
price_text: formatWon(price),
mall_name: extractMallName(fragment),
url,
image_url: extractImageUrl(fragment),
is_ad: /adProduct|광고|ad_/i.test(fragment),
source: "html-card"
});
const key = productDedupKey(product);
if (seen.has(key)) {
continue;
}
seen.add(key);
items.push(product);
}
return items;
}
function normalizeOpenApiSort(sort) {
if (sort === "date") {
return "date";
}
if (sort === "price_asc") {
return "asc";
}
if (sort === "price_dsc") {
return "dsc";
}
return "sim";
}
function getOpenApiSortApplied(sort) {
if (sort === "review") {
return "unsupported";
}
return "upstream";
}
function buildNaverShoppingOpenApiUrl({ query, limit = DEFAULT_LIMIT, page = 1, sort = "rel" } = {}) {
const url = new URL(NAVER_SHOPPING_OPEN_API_URL);
const start = ((Math.max(page, 1) - 1) * limit) + 1;
url.searchParams.set("query", query);
url.searchParams.set("display", String(limit));
url.searchParams.set("start", String(Math.min(start, 1000)));
url.searchParams.set("sort", normalizeOpenApiSort(sort));
return url;
}
function normalizeNaverShoppingOpenApiPayload(payload, { query = null, limit = DEFAULT_LIMIT, sort = "rel" } = {}) {
const items = Array.isArray(payload?.items) ? payload.items : [];
const normalized = [];
const seen = new Set();
const normalizedSort = ALLOWED_SORTS.has(sort) ? sort : "rel";
const upstreamSort = normalizeOpenApiSort(normalizedSort);
for (const item of items) {
if (normalized.length >= limit) {
break;
}
const title = normalizeString(item.title);
const price = parseDigits(item.lprice ?? item.lowPrice ?? item.price);
if (!title || price === null) {
continue;
}
const category = [];
for (const key of ["category1", "category2", "category3", "category4"]) {
const value = normalizeString(item[key]);
if (value && !category.includes(value)) {
category.push(value);
}
}
const product = compactProduct({
rank: normalized.length + 1,
product_id: normalizeString(item.productId),
title,
price,
high_price: parseDigits(item.hprice),
price_text: formatWon(price),
mall_name: normalizeString(item.mallName),
url: normalizeUrl(item.link),
image_url: normalizeUrl(item.image),
brand: normalizeString(item.brand),
maker: normalizeString(item.maker),
product_type: normalizeString(item.productType),
category,
is_ad: false,
source: "naver-openapi"
});
const key = productDedupKey(product);
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push(product);
}
return {
items: normalized,
meta: {
query,
extraction: "naver-openapi",
item_count: normalized.length,
total: parseDigits(payload?.total) ?? 0,
start: parseDigits(payload?.start) ?? null,
display: parseDigits(payload?.display) ?? null,
last_build_date: normalizeString(payload?.lastBuildDate),
sort: normalizedSort,
upstream_sort: upstreamSort,
sort_applied: getOpenApiSortApplied(normalizedSort)
}
};
}
async function fetchNaverShoppingOpenApiSearch({
query,
limit,
page,
sort,
clientId,
clientSecret,
fetchImpl = global.fetch
} = {}) {
const url = buildNaverShoppingOpenApiUrl({ query, limit, page, sort });
const response = await fetchImpl(url, {
headers: {
"X-Naver-Client-Id": clientId,
"X-Naver-Client-Secret": clientSecret,
accept: "application/json"
},
signal: AbortSignal.timeout(15000)
});
const body = await response.text();
if (!response.ok) {
const error = new Error(`Naver Search API responded with ${response.status}.`);
error.code = "upstream_error";
error.statusCode = response.status >= 400 && response.status < 500 ? response.status : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = body.slice(0, 200);
throw error;
}
let payload;
try {
payload = JSON.parse(body);
} catch (cause) {
const error = new Error("Naver Search API returned invalid JSON.");
error.code = "invalid_upstream_response";
error.statusCode = 502;
error.cause = cause;
throw error;
}
const parsed = normalizeNaverShoppingOpenApiPayload(payload, { query, limit, sort });
return {
...parsed,
upstream: {
url: url.toString(),
status_code: response.status,
content_type: response.headers.get("content-type") || null,
provider: "naver-search-api"
}
};
}
function parseNaverShoppingSearchHtml(html, { query = null, limit = DEFAULT_LIMIT } = {}) {
const normalizedLimit = clamp(parseInteger(limit, DEFAULT_LIMIT), 1, MAX_LIMIT);
const payloads = parseJsonScriptPayloads(html);
for (const payload of payloads) {
const items = collectProductsFromJson(payload, normalizedLimit);
if (items.length > 0) {
return {
items,
meta: {
query,
extraction: "embedded-json",
item_count: items.length
}
};
}
}
const items = parseHtmlCards(html, normalizedLimit);
return {
items,
meta: {
query,
extraction: items.length > 0 ? "html-card" : "none",
item_count: items.length
}
};
}
function parseNaverShoppingSearchPayload(payload, { query = null, limit = DEFAULT_LIMIT, page = 1, sort = "rel" } = {}) {
const normalizedLimit = clamp(parseInteger(limit, DEFAULT_LIMIT), 1, MAX_LIMIT);
const requestedPage = Math.max(parseInteger(page, 1), 1);
const normalizedSort = ALLOWED_SORTS.has(sort) ? sort : "rel";
const items = [];
const seen = new Set();
function addCandidate(candidate) {
const product = normalizeBffProductCandidate(candidate, {
rank: items.length + 1,
source: "bff-json"
});
if (!product) {
return;
}
const key = productDedupKey(product);
if (seen.has(key)) {
return;
}
seen.add(key);
items.push(product);
}
const pages = selectBffPages(payload, requestedPage);
for (const page of pages) {
const slots = Array.isArray(page?.slots) ? page.slots : [];
for (const slot of slots) {
addCandidate(slot?.data);
}
}
const sortedItems = applyLocalBffSort(items, normalizedSort).slice(0, normalizedLimit);
const availablePages = (Array.isArray(payload?.data) ? payload.data : [])
.map(getBffPageNumber)
.filter((value) => value !== null);
return {
items: sortedItems.map((item, index) => ({ ...item, rank: index + 1 })),
meta: {
query,
extraction: sortedItems.length > 0 ? "bff-json" : "none",
item_count: sortedItems.length,
page: requestedPage,
page_size: sortedItems.length,
available_pages: availablePages,
sort: normalizedSort,
sort_applied: getBffSortApplied(normalizedSort)
}
};
}
function normalizeNaverShoppingSearchQuery(query) {
const q = trimOrNull(query.q ?? query.query ?? query.keyword);
if (!q) {
throw new Error("Provide q/query.");
}
if ([...q].length < 2) {
throw new Error("q/query must be at least 2 characters.");
}
const rawLimit = parseInteger(query.limit ?? query.size ?? query.pagingSize, DEFAULT_LIMIT);
const rawPage = parseInteger(query.page ?? query.pagingIndex, 1);
const requestedSort = trimOrNull(query.sort) || "rel";
const sort = ALLOWED_SORTS.has(requestedSort) ? requestedSort : "rel";
return {
query: q,
limit: clamp(rawLimit, 1, MAX_LIMIT),
page: Math.max(rawPage, 1),
sort
};
}
function buildNaverShoppingSearchUrl({ query, limit = DEFAULT_LIMIT, page = 1, sort = "rel" } = {}) {
void limit;
void sort;
const url = new URL(NAVER_SHOPPING_SEARCH_PATH, NAVER_SHOPPING_BFF_BASE_URL);
url.searchParams.set("query", query);
url.searchParams.set("source", "shp_gui");
const normalizedPage = Math.max(parseInteger(page, 1), 1);
if (normalizedPage > 1) {
url.searchParams.set("page", String(normalizedPage));
}
return url;
}
async function fetchNaverShoppingSearch({ query, limit, page, sort, clientId = null, clientSecret = null, fetchImpl = global.fetch } = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("fetch is not available in this Node runtime.");
}
if (clientId && clientSecret) {
return fetchNaverShoppingOpenApiSearch({ query, limit, page, sort, clientId, clientSecret, fetchImpl });
}
const url = buildNaverShoppingSearchUrl({ query, limit, page, sort });
const response = await fetchImpl(url, {
headers: {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
accept: "application/json,text/plain;q=0.9,*/*;q=0.8",
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
referer: `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`
},
signal: AbortSignal.timeout(15000)
});
const body = await response.text();
if (!response.ok) {
const error = new Error(`Naver Shopping upstream responded with ${response.status}.`);
error.code = "upstream_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = body.slice(0, 200);
throw error;
}
let parsed;
try {
parsed = parseNaverShoppingSearchPayload(JSON.parse(body), { query, limit, page, sort });
} catch {
parsed = parseNaverShoppingSearchHtml(body, { query, limit });
}
return {
...parsed,
upstream: {
url: url.toString(),
status_code: response.status,
content_type: response.headers.get("content-type") || null,
provider: "naver-shopping-bff"
}
};
}
module.exports = {
buildNaverShoppingOpenApiUrl,
buildNaverShoppingSearchUrl,
fetchNaverShoppingOpenApiSearch,
fetchNaverShoppingSearch,
normalizeNaverShoppingOpenApiPayload,
normalizeNaverShoppingSearchQuery,
parseNaverShoppingSearchPayload,
parseNaverShoppingSearchHtml
};

View file

@ -0,0 +1,183 @@
const { normalizeParkingLotRows } = require("../../parking-lot-search/src/parse");
const PARKING_LOT_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
function parseInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function buildParkingLotApiUrl({
serviceKey,
pageNo = 1,
numOfRows = 1000,
addressHint = null,
addressField = "rdnmadr",
publicOnly = true,
parkingType = null,
apiBaseUrl = PARKING_LOT_API_URL
}) {
const url = new URL(apiBaseUrl);
url.searchParams.set("serviceKey", serviceKey);
url.searchParams.set("pageNo", String(pageNo));
url.searchParams.set("numOfRows", String(numOfRows));
url.searchParams.set("type", "json");
if (publicOnly) {
url.searchParams.set("prkplceSe", "공영");
}
if (parkingType) {
url.searchParams.set("prkplceType", parkingType);
}
if (addressHint) {
url.searchParams.set(addressField, addressHint);
}
return url.toString();
}
async function fetchParkingLotPage({
serviceKey,
pageNo = 1,
numOfRows = 1000,
addressHint = null,
addressField = "rdnmadr",
publicOnly = true,
parkingType = null,
fetchImpl = global.fetch
}) {
const url = buildParkingLotApiUrl({
serviceKey,
pageNo,
numOfRows,
addressHint,
addressField,
publicOnly,
parkingType
});
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
const error = new Error(`Parking lot API HTTP error: ${response.status}`);
error.statusCode = 502;
throw error;
}
const payload = await response.json();
const resultCode = payload?.response?.header?.resultCode;
if (resultCode && resultCode !== "00") {
const error = new Error(payload?.response?.header?.resultMsg || "Parking lot API returned an error.");
error.statusCode = resultCode === "03" ? 404 : 502;
error.upstreamCode = resultCode;
throw error;
}
return payload;
}
function getBody(payload) {
return payload?.response?.body || payload?.body || {};
}
function getItems(payload) {
const items = getBody(payload).items ?? payload?.items ?? [];
if (Array.isArray(items)) {
return items;
}
if (Array.isArray(items.item)) {
return items.item;
}
if (items.item && typeof items.item === "object") {
return [items.item];
}
return [];
}
function mergeParkingLotPayloads(payloads) {
const first = payloads[0] || { response: { header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" }, body: {} } };
const body = getBody(first);
return {
response: {
header: first.response?.header || { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
...body,
items: payloads.flatMap((payload) => getItems(payload)),
pageNo: 1,
numOfRows: payloads.reduce((sum, payload) => sum + getItems(payload).length, 0),
totalCount: body.totalCount ?? payloads.reduce((sum, payload) => sum + getItems(payload).length, 0)
}
}
};
}
async function fetchNearbyParkingLots({
latitude,
longitude,
serviceKey,
limit = 5,
radius = 2000,
addressHint = null,
publicOnly = true,
parkingType = null,
numOfRows = 1000,
maxPages = 1,
fetchImpl = global.fetch
}) {
if (!serviceKey) {
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server."
};
}
const pageCount = Math.max(1, Math.min(10, parseInteger(maxPages, 1)));
const payloads = [];
for (let pageNo = 1; pageNo <= pageCount; pageNo += 1) {
payloads.push(await fetchParkingLotPage({
serviceKey,
pageNo,
numOfRows,
addressHint,
publicOnly,
parkingType,
fetchImpl
}));
}
const mergedPayload = mergeParkingLotPayloads(payloads);
const allItems = normalizeParkingLotRows(mergedPayload, { latitude, longitude }, { radius, publicOnly });
return {
anchor: {
name: "입력 좌표",
address: addressHint,
latitude,
longitude
},
items: allItems.slice(0, limit),
meta: {
total: allItems.length,
limit,
radius,
publicOnly,
addressHint,
numOfRows,
maxPages: pageCount,
source: "data.go.kr"
},
upstream: {
endpoint: PARKING_LOT_API_URL,
pages: pageCount,
total_count: getBody(payloads[0] || {}).totalCount ?? null
}
};
}
module.exports = {
PARKING_LOT_API_URL,
buildParkingLotApiUrl,
fetchNearbyParkingLots,
fetchParkingLotPage
};

View file

@ -14,10 +14,13 @@ const {
normalizeMfdsFoodSafetyQuery
} = require("./mfds");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
@ -147,9 +150,12 @@ function buildConfig(env = process.env) {
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
data4libraryAuthKey: trimOrNull(env.DATA4LIBRARY_AUTH_KEY),
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
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)
@ -157,9 +163,32 @@ function buildConfig(env = process.env) {
}
function makeCacheKey(payload) {
if (!payload || typeof payload !== "object" || typeof payload.route !== "string" || !payload.route) {
throw new Error(
"makeCacheKey requires payload.route as a non-empty string to prevent cross-route key collisions."
);
}
return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");
}
function isFailureResponse(value) {
if (!value || typeof value !== "object") {
return false;
}
if (value.error) {
return true;
}
if (value.upstream && value.upstream.degraded) {
return true;
}
const hasWarnings = Array.isArray(value.warnings) && value.warnings.length > 0;
const emptyItems = Array.isArray(value.items) && value.items.length === 0;
if (hasWarnings && emptyItems) {
return true;
}
return false;
}
function createMemoryCache() {
const entries = new Map();
@ -178,10 +207,14 @@ function createMemoryCache() {
return cached.value;
},
set(key, value, ttlMs) {
if (isFailureResponse(value)) {
return false;
}
entries.set(key, {
value,
expiresAt: Date.now() + ttlMs
});
return true;
}
};
}
@ -230,6 +263,192 @@ function normalizeFineDustQuery(query) {
};
}
function parseBoundedPositiveInteger(value, {
defaultValue,
min = 1,
max = 100,
label
}) {
if (value === undefined || value === null || String(value).trim() === "") {
return defaultValue;
}
const text = String(value).trim();
if (!/^\d+$/.test(text)) {
throw new Error(`Provide valid ${label}.`);
}
const parsed = Number.parseInt(text, 10);
if (parsed < min) {
return min;
}
if (parsed > max) {
return max;
}
return parsed;
}
function normalizeData4LibraryIsbn(value, label = "isbn13") {
const normalized = trimOrNull(value);
if (!normalized) {
throw new Error(`Provide ${label}.`);
}
const compact = normalized.replace(/-/g, "").toUpperCase();
if (!/^(?:\d{9}[\dX]|\d{13})$/.test(compact)) {
throw new Error(`Provide valid ${label} (10 or 13 digits).`);
}
return compact;
}
function parseData4LibraryIsbn10CheckValue(character) {
return character === "X" ? 10 : Number.parseInt(character, 10);
}
function isValidData4LibraryIsbn10(isbn10) {
const sum = [...isbn10].reduce((total, digit, index) => (
total + parseData4LibraryIsbn10CheckValue(digit) * (10 - index)
), 0);
return sum % 11 === 0;
}
function convertData4LibraryIsbn10ToIsbn13(isbn10) {
const body = `978${isbn10.slice(0, 9)}`;
const sum = [...body].reduce((total, digit, index) => (
total + Number.parseInt(digit, 10) * (index % 2 === 0 ? 1 : 3)
), 0);
const checkDigit = (10 - (sum % 10)) % 10;
return `${body}${checkDigit}`;
}
function normalizeData4LibraryIsbn13(value, label = "isbn13") {
const isbn = normalizeData4LibraryIsbn(value, label);
if (isbn.length === 13) {
return isbn;
}
if (!isValidData4LibraryIsbn10(isbn)) {
throw new Error(`Provide valid ${label} (10 or 13 digits).`);
}
return convertData4LibraryIsbn10ToIsbn13(isbn);
}
function normalizeData4LibraryPage(query) {
return {
pageNo: parseBoundedPositiveInteger(query.pageNo ?? query.page_no ?? query.page, {
defaultValue: 1,
min: 1,
max: 1000,
label: "pageNo"
}),
pageSize: parseBoundedPositiveInteger(query.pageSize ?? query.page_size ?? query.limit, {
defaultValue: 10,
min: 1,
max: 100,
label: "pageSize"
})
};
}
function normalizeData4LibraryBookSearchQuery(query) {
const keyword = trimOrNull(query.keyword ?? query.q ?? query.query);
if (!keyword) {
throw new Error("Provide keyword.");
}
return {
keyword,
...normalizeData4LibraryPage(query)
};
}
function normalizeData4LibraryBookDetailQuery(query) {
const isbn13 = normalizeData4LibraryIsbn(query.isbn13 ?? query.isbn, "isbn13");
const loaninfoYN = trimOrNull(query.loaninfoYN ?? query.loanInfoYN ?? query.loan_info_yn ?? query.loanInfo);
if (loaninfoYN && !/^[YN]$/i.test(loaninfoYN)) {
throw new Error("loaninfoYN must be Y or N.");
}
return {
isbn13,
loaninfoYN: loaninfoYN ? loaninfoYN.toUpperCase() : "N"
};
}
function normalizeData4LibraryBookExistsQuery(query) {
const libCode = trimOrNull(query.libCode ?? query.libraryCode ?? query.library_code);
if (!libCode) {
throw new Error("Provide libraryCode.");
}
if (!/^\d+$/.test(libCode)) {
throw new Error("Provide valid libraryCode.");
}
return {
libCode,
isbn13: normalizeData4LibraryIsbn13(query.isbn13 ?? query.isbn, "isbn13")
};
}
function normalizeData4LibraryLibrariesByBookQuery(query) {
const isbn = normalizeData4LibraryIsbn(query.isbn ?? query.isbn13, "isbn");
const region = trimOrNull(query.region);
if (!region) {
throw new Error("Provide region.");
}
if (!/^\d+$/.test(region)) {
throw new Error("Provide valid region.");
}
const normalized = {
isbn,
region,
...normalizeData4LibraryPage(query)
};
const dtlRegion = trimOrNull(query.dtl_region ?? query.dtlRegion ?? query.detailRegion);
if (dtlRegion) {
if (!/^\d+$/.test(dtlRegion)) {
throw new Error("Provide valid dtl_region.");
}
normalized.dtl_region = dtlRegion;
}
return normalized;
}
function normalizeData4LibraryLibrarySearchQuery(query) {
const normalized = normalizeData4LibraryPage(query);
const libCode = trimOrNull(query.libCode ?? query.libraryCode ?? query.library_code);
const region = trimOrNull(query.region);
const dtlRegion = trimOrNull(query.dtl_region ?? query.dtlRegion ?? query.detailRegion);
if (libCode) {
if (!/^\d+$/.test(libCode)) {
throw new Error("Provide valid libraryCode.");
}
normalized.libCode = libCode;
}
if (region) {
if (!/^\d+$/.test(region)) {
throw new Error("Provide valid region.");
}
normalized.region = region;
}
if (dtlRegion) {
if (!/^\d+$/.test(dtlRegion)) {
throw new Error("Provide valid dtl_region.");
}
normalized.dtl_region = dtlRegion;
}
return normalized;
}
function normalizeSeoulSubwayQuery(query) {
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
if (!stationName) {
@ -488,6 +707,57 @@ function normalizeRealEstateQuery(query) {
return { lawdCd, dealYmd, numOfRows };
}
function normalizeParkingLotSearchQuery(query) {
const latitude = parseFloatValue(query.latitude ?? query.lat);
const longitude = parseFloatValue(query.longitude ?? query.lon ?? query.lng);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("Provide latitude and longitude.");
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
throw new Error("Provide valid latitude and longitude.");
}
const limit = parseInteger(query.limit, 5);
if (limit < 1 || limit > 50) {
throw new Error("limit must be between 1 and 50.");
}
const radius = parseInteger(query.radius ?? query.max_distance_meters ?? query.maxDistanceMeters, 2000);
if (radius < 1 || radius > 50000) {
throw new Error("radius must be between 1 and 50000.");
}
const numOfRows = parseInteger(query.numOfRows ?? query.num_of_rows, 1000);
if (numOfRows < 1 || numOfRows > 1000) {
throw new Error("numOfRows must be between 1 and 1000.");
}
const maxPages = parseInteger(query.maxPages ?? query.max_pages, 1);
if (maxPages < 1 || maxPages > 10) {
throw new Error("maxPages must be between 1 and 10.");
}
const publicOnlyRaw = trimOrNull(query.publicOnly ?? query.public_only);
const publicOnly = publicOnlyRaw
? !["0", "false", "n", "no"].includes(publicOnlyRaw.toLowerCase())
: true;
const addressHint = trimOrNull(query.addressHint ?? query.address_hint);
const parkingType = trimOrNull(query.parkingType ?? query.parking_type);
return {
latitude,
longitude,
limit,
radius,
numOfRows,
maxPages,
publicOnly,
addressHint,
parkingType
};
}
function normalizeRegionCodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
@ -702,6 +972,71 @@ async function proxyKmaWeatherRequest({
};
}
async function proxyData4LibraryRequest({
operation,
params = {},
authKey,
fetchImpl = global.fetch
}) {
const allowedOperations = new Set([
"libSrch",
"srchBooks",
"srchDtlList",
"bookExist",
"libSrchByBook"
]);
if (!allowedOperations.has(operation)) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That Data4Library route is not exposed by this proxy."
})
};
}
if (!authKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "DATA4LIBRARY_AUTH_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${DATA4LIBRARY_UPSTREAM_BASE_URL}/${operation}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "authKey" || key === "format") {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null && item !== "") {
url.searchParams.append(key, String(item));
}
}
continue;
}
url.searchParams.set(key, String(value));
}
url.searchParams.set("format", "json");
url.searchParams.set("authKey", authKey);
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()
};
}
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
@ -946,9 +1281,12 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
data4libraryConfigured: Boolean(config.data4libraryAuthKey),
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey)
krxConfigured: Boolean(config.krxApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: Boolean(config.naverSearchClientId && config.naverSearchClientSecret)
},
auth: {
tokenRequired: false
@ -983,7 +1321,10 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
};
}
const cacheKey = makeCacheKey(normalized);
const cacheKey = makeCacheKey({
route: "fine-dust-report",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
@ -1565,6 +1906,101 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/parking-lots/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeParkingLotSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "parking-lot-search",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let result;
try {
result = await fetchNearbyParkingLots({
...normalized,
serviceKey: config.molitApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.upstreamCode ? "upstream_error" : "proxy_error",
message: error.message,
upstream_code: error.upstreamCode || undefined,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const payload = {
...result,
query: {
latitude: normalized.latitude,
longitude: normalized.longitude,
limit: normalized.limit,
radius: normalized.radius,
public_only: normalized.publicOnly,
address_hint: normalized.addressHint,
parking_type: normalized.parkingType,
num_of_rows: normalized.numOfRows,
max_pages: normalized.maxPages
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/household-waste/info", async (request, reply) => {
const query = request.query || {};
const sggNm = query["cond[SGG_NM::LIKE]"];
@ -2067,6 +2503,245 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/naver-shopping/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeNaverShoppingSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "naver-shopping-search",
q: normalized.query.toLowerCase(),
limit: normalized.limit,
page: normalized.page,
sort: normalized.sort
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let result;
try {
result = await fetchNaverShoppingSearch({
...normalized,
clientId: config.naverSearchClientId,
clientSecret: config.naverSearchClientSecret
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
const payload = {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
if (error.upstreamStatusCode) {
payload.upstream = {
status_code: error.upstreamStatusCode,
body_snippet: error.upstreamBodySnippet || null
};
}
return payload;
}
const payload = {
items: result.items,
query: {
q: normalized.query,
limit: normalized.limit,
page: normalized.page,
sort: normalized.sort
},
meta: result.meta,
upstream: result.upstream,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
async function handleData4LibraryRoute({
request,
reply,
route,
operation,
normalize,
queryPayload
}) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route,
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.data4libraryAuthKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA4LIBRARY_AUTH_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let upstream;
try {
upstream = await proxyData4LibraryRequest({
operation,
params: normalized,
authKey: config.data4libraryAuthKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
const looksJson =
upstream.contentType.includes("json") ||
upstream.body.trimStart().startsWith("{") ||
upstream.body.trimStart().startsWith("[");
if (!looksJson) {
return upstream.body;
}
let parsed;
try {
parsed = JSON.parse(upstream.body);
} catch {
return upstream.body;
}
const payload = parsed && !Array.isArray(parsed) && typeof parsed === "object"
? { ...parsed }
: { upstream: parsed };
payload.query = queryPayload ? queryPayload(normalized) : normalized;
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.get("/v1/data4library/library-search", async (request, reply) => handleData4LibraryRoute({
request,
reply,
route: "data4library-library-search",
operation: "libSrch",
normalize: normalizeData4LibraryLibrarySearchQuery
}));
app.get("/v1/data4library/book-search", async (request, reply) => handleData4LibraryRoute({
request,
reply,
route: "data4library-book-search",
operation: "srchBooks",
normalize: normalizeData4LibraryBookSearchQuery
}));
app.get("/v1/data4library/book-detail", async (request, reply) => handleData4LibraryRoute({
request,
reply,
route: "data4library-book-detail",
operation: "srchDtlList",
normalize: normalizeData4LibraryBookDetailQuery
}));
app.get("/v1/data4library/book-exists", async (request, reply) => handleData4LibraryRoute({
request,
reply,
route: "data4library-book-exists",
operation: "bookExist",
normalize: normalizeData4LibraryBookExistsQuery,
queryPayload: (normalized) => ({
library_code: normalized.libCode,
isbn13: normalized.isbn13
})
}));
app.get("/v1/data4library/libraries-by-book", async (request, reply) => handleData4LibraryRoute({
request,
reply,
route: "data4library-libraries-by-book",
operation: "libSrchByBook",
normalize: normalizeData4LibraryLibrariesByBookQuery
}));
app.get("/v1/neis/school-search", async (request, reply) => {
let normalized;
@ -2518,7 +3193,15 @@ module.exports = {
buildConfig,
buildServer,
convertLatLonToKmaGrid,
createMemoryCache,
isFailureResponse,
makeCacheKey,
normalizeBlueRibbonNearbyQuery,
normalizeData4LibraryBookDetailQuery,
normalizeData4LibraryBookExistsQuery,
normalizeData4LibraryBookSearchQuery,
normalizeData4LibraryLibrariesByBookQuery,
normalizeData4LibraryLibrarySearchQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKmaForecastQuery,
@ -2528,14 +3211,18 @@ module.exports = {
normalizeOpinetDetailQuery,
normalizeNeisSchoolMealQuery,
normalizeNeisSchoolSearchQuery,
normalizeNaverShoppingSearchQuery,
normalizeParkingLotSearchQuery,
normalizeRealEstateQuery,
normalizeRegionCodeQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyNeisSchoolMealRequest,
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulSubwayRequest,
resolveLatestKmaForecastBase,

View file

@ -0,0 +1,576 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildNaverShoppingSearchUrl,
normalizeNaverShoppingOpenApiPayload,
normalizeNaverShoppingSearchQuery,
parseNaverShoppingSearchPayload,
parseNaverShoppingSearchHtml
} = require("../src/naver-shopping");
const { buildServer } = require("../src/server");
function nextDataHtml(payload) {
return `<!doctype html><html><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify(payload)}</script></body></html>`;
}
test("parseNaverShoppingSearchHtml extracts normalized products from embedded JSON", () => {
const html = nextDataHtml({
props: {
pageProps: {
initialState: {
products: {
list: [
{
item: {
productId: "1234567890",
productName: "애플 정품 20W USB-C 전원 어댑터",
lowPrice: "23,900",
mallName: "Apple 공식스토어",
productUrl: "/catalog/1234567890?query=%EC%96%B4%EB%8C%91%ED%84%B0",
imageUrl: "//shop-phinf.pstatic.net/main_1234567890.jpg",
category1Name: "디지털/가전",
category2Name: "휴대폰액세서리",
reviewCount: "1,234",
purchaseCnt: "56",
scoreInfo: "4.8"
}
}
]
}
}
}
}
});
const result = parseNaverShoppingSearchHtml(html, { query: "애플 어댑터", limit: 10 });
assert.equal(result.items.length, 1);
assert.deepEqual(result.items[0], {
rank: 1,
product_id: "1234567890",
title: "애플 정품 20W USB-C 전원 어댑터",
price: 23900,
price_text: "23,900원",
mall_name: "Apple 공식스토어",
url: "https://search.shopping.naver.com/catalog/1234567890?query=%EC%96%B4%EB%8C%91%ED%84%B0",
image_url: "https://shop-phinf.pstatic.net/main_1234567890.jpg",
category: ["디지털/가전", "휴대폰액세서리"],
review_count: 1234,
purchase_count: 56,
score: 4.8,
is_ad: false,
source: "embedded-json"
});
assert.equal(result.meta.extraction, "embedded-json");
});
test("parseNaverShoppingSearchHtml falls back to lightweight HTML card extraction", () => {
const html = `<!doctype html>
<html><body>
<div class="basicList_item__abc product_card" data-testid="product-card">
<a class="basicList_link__def product_title" href="https://smartstore.naver.com/acme/products/1">무선 충전기 스탠드</a>
<img src="//shop-phinf.pstatic.net/charger.jpg" alt="무선 충전기 스탠드">
<span class="price">18,500</span>
<span class="mall">ACME스토어</span>
</div>
</body></html>`;
const result = parseNaverShoppingSearchHtml(html, { query: "무선 충전기", limit: 5 });
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "무선 충전기 스탠드");
assert.equal(result.items[0].price, 18500);
assert.equal(result.items[0].price_text, "18,500원");
assert.equal(result.items[0].mall_name, "ACME스토어");
assert.equal(result.items[0].url, "https://smartstore.naver.com/acme/products/1");
assert.equal(result.items[0].image_url, "https://shop-phinf.pstatic.net/charger.jpg");
assert.equal(result.items[0].source, "html-card");
assert.equal(result.meta.extraction, "html-card");
});
test("normalizeNaverShoppingSearchQuery validates q/query and clamps limit", () => {
assert.throws(() => normalizeNaverShoppingSearchQuery({}), /Provide q\/query/);
assert.throws(() => normalizeNaverShoppingSearchQuery({ q: "a" }), /at least 2/);
assert.deepEqual(normalizeNaverShoppingSearchQuery({ query: " 아이폰 케이스 ", limit: "99", page: "0", sort: "price_asc" }), {
query: "아이폰 케이스",
limit: 40,
page: 1,
sort: "price_asc"
});
const url = buildNaverShoppingSearchUrl({ query: "아이폰 케이스", limit: 40, page: 1, sort: "price_asc" });
assert.equal(url.hostname, "ns-portal.shopping.naver.com");
assert.equal(url.pathname, "/api/v2/shopping-paged-slot");
assert.equal(url.searchParams.get("query"), "아이폰 케이스");
assert.equal(url.searchParams.get("source"), "shp_gui");
const pageUrl = buildNaverShoppingSearchUrl({ query: "아이폰 케이스", limit: 3, page: 2, sort: "price_asc" });
assert.equal(pageUrl.searchParams.get("page"), "2");
assert.equal(pageUrl.searchParams.has("sort"), false);
});
test("parseNaverShoppingSearchPayload maps public BFF shopping-paged-slot cards", () => {
const result = parseNaverShoppingSearchPayload({
data: [
{
page: 1,
pageSize: 8,
slots: [
{
slotType: "CARD",
data: {
rank: 1,
nvMid: 89011025048,
productNameOrg: "애플 <mark>에어팟</mark> 4세대 ANC",
productUrl: {
pcUrl: "https://smartstore.naver.com/main/products/11466514676",
mobileUrl: "https://m.smartstore.naver.com/main/products/11466514676"
},
productClickUrl: {
pcUrl: "https://cr3.shopping.naver.com/v2/bridge/searchGate?nv_mid=89011025048"
},
images: [
{
imageUrl: "https://shopping-phinf.pstatic.net/main_8901102/89011025048.jpg",
width: 1000,
height: 1000
}
],
mallName: "오렌지스펙트럼",
salePrice: 269000,
discountedSalePrice: 254900,
totalReviewCount: 820,
purchaseCount: 1214,
averageReviewScore: 4.91,
isBrandStore: true
}
}
]
}
]
}, { query: "에어팟", limit: 10 });
assert.equal(result.items.length, 1);
assert.deepEqual(result.items[0], {
rank: 1,
product_id: "89011025048",
title: "애플 에어팟 4세대 ANC",
price: 254900,
high_price: 269000,
price_text: "254,900원",
mall_name: "오렌지스펙트럼",
url: "https://smartstore.naver.com/main/products/11466514676",
image_url: "https://shopping-phinf.pstatic.net/main_8901102/89011025048.jpg",
review_count: 820,
purchase_count: 1214,
score: 4.91,
is_ad: false,
is_brand_store: true,
source: "bff-json"
});
assert.equal(result.meta.extraction, "bff-json");
assert.equal(result.meta.item_count, 1);
});
test("parseNaverShoppingSearchPayload selects requested BFF page and locally sorts comparable cards", () => {
const payload = {
data: [
{
page: 1,
pageSize: 2,
slots: [
{
data: {
nvMid: "page1-expensive",
productNameOrg: "1페이지 비싼 상품",
salePrice: 30000,
mallName: "첫페이지몰"
}
},
{
data: {
nvMid: "page1-cheap",
productNameOrg: "1페이지 싼 상품",
salePrice: 10000,
mallName: "첫페이지몰"
}
}
]
},
{
page: 2,
pageSize: 3,
slots: [
{
data: {
nvMid: "page2-mid",
productNameOrg: "2페이지 중간 상품",
salePrice: 50000,
totalReviewCount: 4,
mallName: "둘째페이지몰"
}
},
{
data: {
nvMid: "page2-cheap",
productNameOrg: "2페이지 싼 상품",
salePrice: 20000,
totalReviewCount: 9,
mallName: "둘째페이지몰"
}
},
{
data: {
nvMid: "page2-reviewed",
productNameOrg: "2페이지 리뷰 많은 상품",
salePrice: 70000,
totalReviewCount: 50,
mallName: "둘째페이지몰"
}
}
]
}
]
};
const priceAscending = parseNaverShoppingSearchPayload(payload, {
query: "테스트 상품",
limit: 3,
page: 2,
sort: "price_asc"
});
assert.deepEqual(priceAscending.items.map((item) => item.product_id), ["page2-cheap", "page2-mid", "page2-reviewed"]);
assert.equal(priceAscending.meta.page, 2);
assert.equal(priceAscending.meta.page_size, 3);
assert.equal(priceAscending.meta.sort, "price_asc");
assert.equal(priceAscending.meta.sort_applied, "local");
const reviewDescending = parseNaverShoppingSearchPayload(payload, {
query: "테스트 상품",
limit: 2,
page: 2,
sort: "review"
});
assert.deepEqual(reviewDescending.items.map((item) => item.product_id), ["page2-reviewed", "page2-cheap"]);
assert.equal(reviewDescending.meta.item_count, 2);
});
test("normalizeNaverShoppingOpenApiPayload maps official Search API shopping results", () => {
const result = normalizeNaverShoppingOpenApiPayload({
lastBuildDate: "Sat, 18 Apr 2026 20:30:00 +0900",
total: 123,
start: 1,
display: 1,
items: [
{
title: "<b>에어팟</b> 프로 2세대",
link: "https://search.shopping.naver.com/catalog/111",
image: "https://shopping-phinf.pstatic.net/main_111.jpg",
lprice: "289000",
hprice: "349000",
mallName: "네이버",
productId: "111",
productType: "1",
brand: "Apple",
maker: "Apple",
category1: "디지털/가전",
category2: "음향가전"
}
]
}, { query: "에어팟", limit: 10 });
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "에어팟 프로 2세대");
assert.equal(result.items[0].price, 289000);
assert.equal(result.items[0].high_price, 349000);
assert.equal(result.items[0].mall_name, "네이버");
assert.equal(result.items[0].brand, "Apple");
assert.equal(result.items[0].source, "naver-openapi");
assert.equal(result.meta.extraction, "naver-openapi");
assert.equal(result.meta.total, 123);
});
test("naver shopping search endpoint returns normalized comparison payload from mocked upstream", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
data: [
{
page: 1,
pageSize: 8,
slots: [
{
slotType: "CARD",
data: {
nvMid: "987",
productTitle: "네이버 테스트 상품",
salePrice: "12,340원",
discountedSalePrice: "11,990원",
mallName: "테스트몰",
productUrl: {
pcUrl: "https://smartstore.naver.com/test/products/987"
},
images: [
{
imageUrl: "https://shop-phinf.pstatic.net/test.jpg"
}
]
}
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KSKILL_PROXY_CACHE_TTL_MS: "60000" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const bad = await app.inject({ method: "GET", url: "/v1/naver-shopping/search" });
assert.equal(bad.statusCode, 400);
assert.equal(bad.json().error, "bad_request");
const response = await app.inject({
method: "GET",
url: "/v1/naver-shopping/search?q=%EB%84%A4%EC%9D%B4%EB%B2%84%20%ED%85%8C%EC%8A%A4%ED%8A%B8&limit=99&sort=price_asc"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.query.q, "네이버 테스트");
assert.equal(body.query.limit, 40);
assert.equal(body.query.sort, "price_asc");
assert.equal(body.items.length, 1);
assert.equal(body.items[0].title, "네이버 테스트 상품");
assert.equal(body.items[0].price, 11990);
assert.equal(body.items[0].high_price, 12340);
assert.equal(body.items[0].mall_name, "테스트몰");
assert.equal(body.proxy.cache.hit, false);
assert.equal(body.upstream.status_code, 200);
assert.equal(body.upstream.provider, "naver-shopping-bff");
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0].url, /^https:\/\/ns-portal\.shopping\.naver\.com\/api\/v2\/shopping-paged-slot\?/);
assert.match(fetchCalls[0].url, /source=shp_gui/);
assert.match(fetchCalls[0].headers["user-agent"], /Mozilla\/5\.0/);
assert.match(fetchCalls[0].headers.referer, /^https:\/\/search\.naver\.com\/search\.naver\?/);
});
test("naver shopping BFF fallback fetches requested page and applies local comparable sort", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
data: [
{
page: 1,
pageSize: 2,
slots: [
{
data: {
nvMid: "page1-first",
productNameOrg: "첫 페이지 상품",
discountedSalePrice: 99000,
mallName: "첫페이지몰"
}
}
]
},
{
page: 2,
pageSize: 3,
slots: [
{
data: {
nvMid: "page2-expensive",
productNameOrg: "둘째 페이지 비싼 상품",
discountedSalePrice: 50000,
mallName: "둘째페이지몰"
}
},
{
data: {
nvMid: "page2-cheap",
productNameOrg: "둘째 페이지 싼 상품",
discountedSalePrice: 12000,
mallName: "둘째페이지몰"
}
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KSKILL_PROXY_CACHE_TTL_MS: "60000" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-shopping/search?q=%ED%85%8C%EC%8A%A4%ED%8A%B8%20%EC%83%81%ED%92%88&limit=2&page=2&sort=price_asc"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.deepEqual(body.items.map((item) => item.product_id), ["page2-cheap", "page2-expensive"]);
assert.deepEqual(body.items.map((item) => item.rank), [1, 2]);
assert.equal(body.query.page, 2);
assert.equal(body.query.sort, "price_asc");
assert.equal(body.meta.page, 2);
assert.equal(body.meta.sort, "price_asc");
assert.equal(body.meta.sort_applied, "local");
assert.equal(fetchCalls.length, 1);
const upstreamUrl = new URL(fetchCalls[0].url);
assert.equal(upstreamUrl.hostname, "ns-portal.shopping.naver.com");
assert.equal(upstreamUrl.searchParams.get("page"), "2");
assert.equal(upstreamUrl.searchParams.has("sort"), false);
});
test("naver shopping search endpoint prefers official Search API when server keys are configured", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
lastBuildDate: "Sat, 18 Apr 2026 20:30:00 +0900",
total: 1,
start: 1,
display: 1,
items: [
{
title: "<b>네이버</b> 공식 API 상품",
link: "https://search.shopping.naver.com/catalog/222",
image: "https://shopping-phinf.pstatic.net/main_222.jpg",
lprice: "45600",
hprice: "49900",
mallName: "공식몰",
productId: "222",
brand: "브랜드",
category1: "생활/건강"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-shopping/search?q=%EB%84%A4%EC%9D%B4%EB%B2%84%20%EA%B3%B5%EC%8B%9D&limit=99&sort=price_asc"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.items[0].source, "naver-openapi");
assert.equal(body.items[0].title, "네이버 공식 API 상품");
assert.equal(body.items[0].price, 45600);
assert.equal(body.query.limit, 40);
assert.equal(body.meta.extraction, "naver-openapi");
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0].url, /^https:\/\/openapi\.naver\.com\/v1\/search\/shop\.json\?/);
assert.match(fetchCalls[0].url, /display=40/);
assert.match(fetchCalls[0].url, /sort=asc/);
assert.equal(fetchCalls[0].headers["X-Naver-Client-Id"], "client-id");
assert.equal(fetchCalls[0].headers["X-Naver-Client-Secret"], "client-secret");
});
test("naver shopping official Search API marks review sort as unsupported instead of silently applying relevance", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
lastBuildDate: "Sat, 18 Apr 2026 20:30:00 +0900",
total: 2,
start: 1,
display: 2,
items: [
{
title: "리뷰 정렬 요청 첫 번째 상품",
link: "https://search.shopping.naver.com/catalog/333",
image: "https://shopping-phinf.pstatic.net/main_333.jpg",
lprice: "30000",
mallName: "공식몰A",
productId: "333"
},
{
title: "리뷰 정렬 요청 두 번째 상품",
link: "https://search.shopping.naver.com/catalog/444",
image: "https://shopping-phinf.pstatic.net/main_444.jpg",
lprice: "20000",
mallName: "공식몰B",
productId: "444"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-shopping/search?q=%EB%A6%AC%EB%B7%B0%20%EC%A0%95%EB%A0%AC&limit=5&sort=review"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.query.sort, "review");
assert.equal(body.upstream.provider, "naver-search-api");
assert.equal(body.meta.extraction, "naver-openapi");
assert.equal(body.meta.sort, "review");
assert.equal(body.meta.upstream_sort, "sim");
assert.equal(body.meta.sort_applied, "unsupported");
assert.deepEqual(body.items.map((item) => item.product_id), ["333", "444"]);
assert.equal(fetchCalls.length, 1);
const upstreamUrl = new URL(fetchCalls[0].url);
assert.equal(upstreamUrl.hostname, "openapi.naver.com");
assert.equal(upstreamUrl.searchParams.get("sort"), "sim");
});

View file

@ -3,13 +3,148 @@ const assert = require("node:assert/strict");
const {
buildServer,
createMemoryCache,
isFailureResponse,
makeCacheKey,
normalizeData4LibraryBookDetailQuery,
normalizeData4LibraryBookExistsQuery,
normalizeData4LibraryBookSearchQuery,
normalizeData4LibraryLibrariesByBookQuery,
normalizeData4LibraryLibrarySearchQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
test("makeCacheKey requires a non-empty route to prevent cross-route collisions", () => {
assert.throws(() => makeCacheKey({ q: "강남" }), /route/);
assert.throws(() => makeCacheKey({ route: "", q: "강남" }), /route/);
assert.throws(() => makeCacheKey(null), /route/);
assert.throws(() => makeCacheKey(undefined), /route/);
const sameShapeA = makeCacheKey({ route: "alpha", q: "x" });
const sameShapeB = makeCacheKey({ route: "beta", q: "x" });
assert.notEqual(sameShapeA, sameShapeB, "different routes must yield different cache keys");
const duplicate = makeCacheKey({ route: "alpha", q: "x" });
assert.equal(sameShapeA, duplicate, "same input must produce same key");
});
test("isFailureResponse flags explicit errors, upstream degradation, and empty-items-with-warnings", () => {
assert.equal(isFailureResponse({ error: "bad_request", message: "..." }), true);
assert.equal(isFailureResponse({ upstream: { degraded: true } }), true);
assert.equal(
isFailureResponse({ items: [], warnings: ["upstream invalid"] }),
true,
"empty items plus warnings must be treated as failure"
);
assert.equal(isFailureResponse({ items: [{ id: 1 }], warnings: ["sample feed"] }), false,
"non-empty items with warnings is a graceful fallback, not a failure");
assert.equal(isFailureResponse({ items: [] }), false, "empty items alone is valid (zero hits)");
assert.equal(isFailureResponse({ warnings: [] }), false);
assert.equal(isFailureResponse({ forecast: [], baseDate: "20260420" }), false,
"routes without items/warnings convention pass through");
assert.equal(isFailureResponse(null), false);
assert.equal(isFailureResponse("string"), false);
});
test("createMemoryCache refuses to store failure responses", () => {
const cache = createMemoryCache();
assert.equal(cache.set("k1", { items: [], warnings: ["upstream invalid"] }, 60000), false);
assert.equal(cache.get("k1"), null, "failure payload must not be persisted");
assert.equal(cache.set("k2", { error: "bad_request" }, 60000), false);
assert.equal(cache.get("k2"), null);
assert.equal(cache.set("k3", { upstream: { degraded: true } }, 60000), false);
assert.equal(cache.get("k3"), null);
assert.equal(cache.set("k4", { items: [{ id: 1 }] }, 60000), true);
assert.deepEqual(cache.get("k4"), { items: [{ id: 1 }] }, "successful payload must be stored");
});
test("food-safety search does not cache upstream failures so transient errors self-heal", async (t) => {
const originalFetch = global.fetch;
const recallCalls = [];
let recallMode = "fail";
global.fetch = async (url) => {
const text = String(url);
if (text.includes("PrsecImproptFoodInfoService03/getPrsecImproptFoodList01")) {
return new Response(
JSON.stringify({ body: { items: [] } }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
}
if (text.includes("openapi.foodsafetykorea.go.kr/api/live-food-key/I0490/json/1/50")) {
recallCalls.push(recallMode);
if (recallMode === "fail") {
return new Response(
"<script>alert('인증키가 유효하지 않습니다.'); history.back();</script>",
{ status: 200, headers: { "content-type": "text/html" } }
);
}
return new Response(
JSON.stringify({
I0490: {
row: [
{
PRDLST_NM: "재시도 성공 식품",
BSSH_NM: "회복푸드",
RTRVLPRVNS: "회수 조치"
}
]
}
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-key",
FOODSAFETYKOREA_API_KEY: "live-food-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = `/v1/mfds/food-safety/search?query=${encodeURIComponent("재시도 성공 식품")}&limit=3`;
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
assert.equal(first.json().items.length, 0);
assert.match(first.json().warnings.join(" "), /not valid JSON/);
assert.equal(first.json().proxy.cache.hit, false);
recallMode = "ok";
const second = await app.inject({ method: "GET", url });
assert.equal(second.statusCode, 200);
assert.equal(
second.json().proxy.cache.hit,
false,
"failure response must not have been cached - retry must hit upstream"
);
assert.equal(second.json().items.length, 1);
assert.equal(second.json().items[0].product_name, "재시도 성공 식품");
const third = await app.inject({ method: "GET", url });
assert.equal(third.json().proxy.cache.hit, true, "successful payload must now be cached");
assert.equal(third.json().items[0].product_name, "재시도 성공 식품");
assert.equal(recallCalls.length, 2, "upstream hit on first (fail) and second (recovered) - third served from cache");
});
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
provider: async () => {
@ -35,6 +170,7 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
assert.equal(body.upstreams.krxConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
assert.equal(body.upstreams.hrfcoConfigured, false);
assert.equal(body.upstreams.data4libraryConfigured, false);
});
test("health endpoint reports KRX upstream status when configured", async (t) => {
@ -2292,16 +2428,14 @@ test("mfds drug-safety lookup endpoint proxies official drug surfaces and caches
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
itemName: "타이레놀정160밀리그램",
entpName: "한국얀센",
efcyQesitm: "감기로 인한 발열 및 동통에 사용합니다.",
intrcQesitm: "다른 해열진통제와 함께 복용하지 마십시오."
}
]
}
items: [
{
itemName: "타이레놀정160밀리그램",
entpName: "한국얀센",
efcyQesitm: "감기로 인한 발열 및 동통에 사용합니다.",
intrcQesitm: "다른 해열진통제와 함께 복용하지 마십시오."
}
]
}
}),
{
@ -2315,16 +2449,14 @@ test("mfds drug-safety lookup endpoint proxies official drug surfaces and caches
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
PRDLST_NM: "판콜에스내복액",
BSSH_NM: "동화약품",
EFCY_QESITM: "감기 증상 완화",
INTRC_QESITM: "다른 감기약과 병용 주의"
}
]
}
items: [
{
PRDLST_NM: "판콜에스내복액",
BSSH_NM: "동화약품",
EFCY_QESITM: "감기 증상 완화",
INTRC_QESITM: "다른 감기약과 병용 주의"
}
]
}
}),
{
@ -2364,6 +2496,78 @@ test("mfds drug-safety lookup endpoint proxies official drug surfaces and caches
assert.ok(fetchCalls.every((entry) => entry.includes("test-key")));
});
test("mfds drug-safety lookup endpoint still parses legacy body.items.item shape", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
const text = String(url);
if (text.includes("DrbEasyDrugInfoService/getDrbEasyDrugList")) {
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
itemName: "레거시타이레놀",
entpName: "레거시얀센"
}
]
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("SafeStadDrugService/getSafeStadDrugInq")) {
return new Response(
JSON.stringify({
body: {
items: {
item: {
PRDLST_NM: "레거시판콜",
BSSH_NM: "레거시동화"
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "legacy-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/mfds/drug-safety/lookup?itemName=%ED%83%80%EC%9D%B4%EB%A0%88%EB%86%80&limit=5"
});
assert.equal(response.statusCode, 200);
const items = response.json().items;
assert.equal(items.length, 2);
assert.equal(items[0].source, "drug_easy_info");
assert.equal(items[0].item_name, "레거시타이레놀");
assert.equal(items[1].source, "safe_standby_medicine");
assert.equal(items[1].item_name, "레거시판콜");
});
test("mfds food-safety search endpoint requires query", async (t) => {
const app = buildServer();
@ -2444,16 +2648,16 @@ test("mfds food-safety search endpoint uses live recall key when configured", as
return new Response(
JSON.stringify({
body: {
items: {
item: [
{
items: [
{
item: {
PRDUCT: "예시 유부초밥",
ENTRPS: "예시푸드",
IMPROPT_ITM: "황색포도상구균",
INSPCT_RESULT: "기준 부적합"
}
]
}
}
]
}
}),
{
@ -2869,3 +3073,532 @@ test("mfds product-report endpoint uses live key with server-side filter", async
assert.ok(fetchCalls.some((entry) => entry.includes("RAWMTRL_NM=")));
assert.ok(!response.json().warnings || !response.json().warnings.some((w) => /sample feed/.test(w)));
});
test("data4library book-search normalizes query aliases and bounds pagination", () => {
const normalized = normalizeData4LibraryBookSearchQuery({
q: " 역사 ",
page: "2",
limit: "250"
});
assert.deepEqual(normalized, {
keyword: "역사",
pageNo: 2,
pageSize: 100
});
assert.throws(
() => normalizeData4LibraryBookSearchQuery({ pageNo: "1" }),
/Provide keyword/
);
});
test("data4library remaining query normalizers preserve narrow aliases", () => {
assert.deepEqual(
normalizeData4LibraryBookDetailQuery({
isbn: "978-8971998557",
loanInfo: "y"
}),
{
isbn13: "9788971998557",
loaninfoYN: "Y"
}
);
assert.deepEqual(
normalizeData4LibraryBookDetailQuery({
isbn: "0-306-40615-2"
}),
{
isbn13: "0306406152",
loaninfoYN: "N"
}
);
assert.deepEqual(
normalizeData4LibraryBookExistsQuery({
libraryCode: "111001",
isbn13: "0-306-40615-2"
}),
{
libCode: "111001",
isbn13: "9780306406157"
}
);
assert.deepEqual(
normalizeData4LibraryBookExistsQuery({
libraryCode: "111001",
isbn13: "0-8044-2957-X"
}),
{
libCode: "111001",
isbn13: "9780804429573"
}
);
assert.deepEqual(
normalizeData4LibraryLibrariesByBookQuery({
isbn13: "9788971998557",
region: "11",
detailRegion: "11010",
page: "2",
limit: "250"
}),
{
isbn: "9788971998557",
region: "11",
pageNo: 2,
pageSize: 100,
dtl_region: "11010"
}
);
assert.deepEqual(
normalizeData4LibraryLibrarySearchQuery({
libraryCode: "111001",
region: "11",
detailRegion: "11010",
page: "3",
limit: "20"
}),
{
pageNo: 3,
pageSize: 20,
libCode: "111001",
region: "11",
dtl_region: "11010"
}
);
assert.throws(
() => normalizeData4LibraryBookDetailQuery({ isbn13: "9788971998557", loanInfo: "maybe" }),
/loaninfoYN must be Y or N/
);
assert.throws(
() => normalizeData4LibraryBookExistsQuery({ libraryCode: "lib-1", isbn13: "9788971998557" }),
/Provide valid libraryCode/
);
assert.throws(
() => normalizeData4LibraryBookExistsQuery({ libraryCode: "111001", isbn13: "0-306-40615-3" }),
/Provide valid isbn13/
);
assert.throws(
() => normalizeData4LibraryLibrariesByBookQuery({ isbn13: "9788971998557" }),
/Provide region/
);
assert.throws(
() => normalizeData4LibraryLibrarySearchQuery({ detailRegion: "seoul" }),
/Provide valid dtl_region/
);
});
test("data4library proxy helper injects authKey and strips caller auth overrides", async () => {
const fetchCalls = [];
const upstream = await proxyData4LibraryRequest({
operation: "srchBooks",
params: {
keyword: "역사",
pageNo: 1,
pageSize: 10,
authKey: "caller-key",
format: "xml"
},
authKey: "server-key",
fetchImpl: async (url) => {
fetchCalls.push(String(url));
return new Response(JSON.stringify({ response: { resultNum: 0, docs: [] } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(upstream.statusCode, 200);
assert.equal(fetchCalls.length, 1);
const url = new URL(fetchCalls[0]);
assert.equal(url.origin + url.pathname, "https://data4library.kr/api/srchBooks");
assert.equal(url.searchParams.get("authKey"), "server-key");
assert.equal(url.searchParams.get("keyword"), "역사");
assert.equal(url.searchParams.get("format"), "json");
});
test("data4library book-search endpoint reports 503 without DATA4LIBRARY_AUTH_KEY", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/data4library/book-search?keyword=%EC%97%AD%EC%82%AC"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("data4library book-search endpoint proxies JSON and caches normalized query aliases", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.startsWith("https://data4library.kr/api/srchBooks")) {
return new Response(
JSON.stringify({
response: {
resultNum: 1,
docs: [
{
doc: {
bookname: "역사의 역사",
authors: "유시민",
publisher: "돌베개",
publication_year: "2018",
isbn13: "9788971998557"
}
}
]
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-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/data4library/book-search?q=%20%EC%97%AD%EC%82%AC%20&page=1&limit=10&authKey=caller-key"
});
const second = await app.inject({
method: "GET",
url: "/v1/data4library/book-search?keyword=%EC%97%AD%EC%82%AC&pageNo=1&pageSize=10"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().query.keyword, "역사");
const upstream = new URL(fetchCalls[0]);
assert.equal(upstream.searchParams.get("authKey"), "server-key");
assert.equal(upstream.searchParams.get("format"), "json");
assert.equal(upstream.searchParams.get("authKey") === "caller-key", false);
});
test("data4library detail, libraries-by-book, and library-search endpoints proxy expected operations", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
return new Response(
JSON.stringify({
response: {
echoedPath: new URL(text).pathname
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const detail = await app.inject({
method: "GET",
url: "/v1/data4library/book-detail?isbn=978-8971998557&loanInfo=y&authKey=caller-key&format=xml"
});
const librariesByBook = await app.inject({
method: "GET",
url: "/v1/data4library/libraries-by-book?isbn13=9788971998557&region=11&detailRegion=11010&page=2&limit=20"
});
const librarySearch = await app.inject({
method: "GET",
url: "/v1/data4library/library-search?libraryCode=111001&region=11&detailRegion=11010&page=3&pageSize=30"
});
assert.equal(detail.statusCode, 200);
assert.equal(librariesByBook.statusCode, 200);
assert.equal(librarySearch.statusCode, 200);
assert.equal(fetchCalls.length, 3);
const [detailUrl, librariesByBookUrl, librarySearchUrl] = fetchCalls.map((entry) => new URL(entry));
assert.equal(detailUrl.origin + detailUrl.pathname, "https://data4library.kr/api/srchDtlList");
assert.equal(detailUrl.searchParams.get("isbn13"), "9788971998557");
assert.equal(detailUrl.searchParams.get("loaninfoYN"), "Y");
assert.equal(librariesByBookUrl.origin + librariesByBookUrl.pathname, "https://data4library.kr/api/libSrchByBook");
assert.equal(librariesByBookUrl.searchParams.get("isbn"), "9788971998557");
assert.equal(librariesByBookUrl.searchParams.get("region"), "11");
assert.equal(librariesByBookUrl.searchParams.get("dtl_region"), "11010");
assert.equal(librariesByBookUrl.searchParams.get("pageNo"), "2");
assert.equal(librariesByBookUrl.searchParams.get("pageSize"), "20");
assert.equal(librarySearchUrl.origin + librarySearchUrl.pathname, "https://data4library.kr/api/libSrch");
assert.equal(librarySearchUrl.searchParams.get("libCode"), "111001");
assert.equal(librarySearchUrl.searchParams.get("region"), "11");
assert.equal(librarySearchUrl.searchParams.get("dtl_region"), "11010");
assert.equal(librarySearchUrl.searchParams.get("pageNo"), "3");
assert.equal(librarySearchUrl.searchParams.get("pageSize"), "30");
for (const upstream of [detailUrl, librariesByBookUrl, librarySearchUrl]) {
assert.equal(upstream.searchParams.get("authKey"), "server-key");
assert.equal(upstream.searchParams.get("format"), "json");
}
assert.deepEqual(detail.json().query, {
isbn13: "9788971998557",
loaninfoYN: "Y"
});
assert.deepEqual(librariesByBook.json().query, {
isbn: "9788971998557",
region: "11",
pageNo: 2,
pageSize: 20,
dtl_region: "11010"
});
assert.deepEqual(librarySearch.json().query, {
pageNo: 3,
pageSize: 30,
libCode: "111001",
region: "11",
dtl_region: "11010"
});
});
test("data4library book-exists endpoint requires library code and isbn13 then proxies", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
fetchCalls.push(String(url));
return new Response(JSON.stringify({ response: { result: { hasBook: "Y", loanAvailable: "N" } } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
DATA4LIBRARY_AUTH_KEY: "server-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const bad = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=abc"
});
const invalidIsbn10 = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-306-40615-3"
});
const ok = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=9788971998557"
});
const isbn10 = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-306-40615-2"
});
const isbn10X = await app.inject({
method: "GET",
url: "/v1/data4library/book-exists?libraryCode=111001&isbn13=0-8044-2957-X"
});
assert.equal(bad.statusCode, 400);
assert.equal(invalidIsbn10.statusCode, 400);
assert.equal(invalidIsbn10.json().error, "bad_request");
assert.equal(ok.statusCode, 200);
assert.equal(isbn10.statusCode, 200);
assert.equal(isbn10X.statusCode, 200);
assert.equal(fetchCalls.length, 3);
const upstream = new URL(fetchCalls[0]);
assert.equal(upstream.origin + upstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(upstream.searchParams.get("libCode"), "111001");
assert.equal(upstream.searchParams.get("isbn13"), "9788971998557");
const isbn10Upstream = new URL(fetchCalls[1]);
assert.equal(isbn10Upstream.origin + isbn10Upstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(isbn10Upstream.searchParams.get("libCode"), "111001");
assert.equal(isbn10Upstream.searchParams.get("isbn13"), "9780306406157");
assert.deepEqual(isbn10.json().query, {
library_code: "111001",
isbn13: "9780306406157"
});
const isbn10XUpstream = new URL(fetchCalls[2]);
assert.equal(isbn10XUpstream.origin + isbn10XUpstream.pathname, "https://data4library.kr/api/bookExist");
assert.equal(isbn10XUpstream.searchParams.get("libCode"), "111001");
assert.equal(isbn10XUpstream.searchParams.get("isbn13"), "9780804429573");
assert.deepEqual(isbn10X.json().query, {
library_code: "111001",
isbn13: "9780804429573"
});
});
test("parking lot search endpoint normalizes, caches, and keeps the proxy public", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const resolved = String(url);
fetchCalls.push(resolved);
assert.match(resolved, /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
const urlObject = new URL(resolved);
assert.equal(urlObject.searchParams.get("serviceKey"), "data-key");
assert.equal(urlObject.searchParams.get("type"), "json");
assert.equal(urlObject.searchParams.get("prkplceSe"), "공영");
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
return new Response(JSON.stringify({
response: {
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
body: {
pageNo: 1,
numOfRows: 1000,
totalCount: 2,
items: [
{
prkplceNo: "111-2-000001",
prkplceNm: "종로구청 공영주차장",
prkplceSe: "공영",
prkplceType: "노외",
rdnmadr: "서울특별시 종로구 삼봉로 43",
prkcmprt: "50",
parkingchrgeInfo: "유료",
basicTime: "30",
basicCharge: "1000",
institutionNm: "서울특별시 종로구청",
latitude: "37.57320",
longitude: "126.97810",
pwdbsPpkZoneYn: "Y",
referenceDate: "2026-03-01"
},
{
prkplceNo: "111-2-000003",
prkplceNm: "광화문광장 공영주차장",
prkplceSe: "공영",
prkplceType: "노상",
rdnmadr: "서울특별시 종로구 세종대로 172",
prkcmprt: "20",
parkingchrgeInfo: "무료",
latitude: "37.57375",
longitude: "126.97836",
pwdbsPpkZoneYn: "N",
referenceDate: "2026-03-01"
}
]
}
}
}), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-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/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&address_hint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
});
const second = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&addressHint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 1);
assert.equal(first.json().items[0].name, "광화문광장 공영주차장");
assert.equal(first.json().items[0].feeInfo, "무료");
assert.equal(first.json().items[1].basicCharge, 1000);
assert.equal(first.json().query.address_hint, "서울특별시 종로구");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("parking lot search endpoint validates coordinates before upstream calls", async (t) => {
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-key"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=oops&longitude=126.9"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.match(response.json().message, /latitude and longitude/);
});
test("parking lot search endpoint reports missing Data.go.kr key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});

View file

@ -0,0 +1,68 @@
# parking-lot-search
Korean parking-lot lookup backed first by the official Data.go.kr `전국주차장정보표준데이터` Open API.
## Usage principles
- Do not infer or track the user's location automatically.
- Ask for the current location first, then use a neighborhood/station/landmark or `latitude, longitude` pair.
- The default result set is public (`공영`) parking lots only.
- The package resolves text locations through Kakao Map place search, then searches the official parking dataset near the resolved coordinates.
- Live occupancy and reservation are not provided by the official standard data.
## Official surfaces
- Data.go.kr standard dataset: `https://www.data.go.kr/data/15012896/standard.do`
- Data.go.kr Open API docs: `https://www.data.go.kr/data/15012896/openapi.do`
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Proxy-first usage
By default, the package calls the shared k-skill proxy:
```js
const { searchNearbyParkingLotsByLocationQuery } = require("parking-lot-search");
async function main() {
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
limit: 3,
radius: 1500
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
Set `KSKILL_PROXY_BASE_URL` or pass `proxyBaseUrl` to use a local proxy.
## Direct Data.go.kr usage
For direct official API calls, pass `apiKey` or set `DATA_GO_KR_API_KEY` and `useDirectApi: true`.
```js
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
apiKey: process.env.DATA_GO_KR_API_KEY,
limit: 5,
radius: 2000
});
```
## Public API
- `parseCoordinateQuery(locationQuery)`
- `normalizeParkingLotRows(payload, origin, options)`
- `buildOfficialParkingLotApiUrl(options)`
- `searchNearbyParkingLotsByCoordinates(options)`
- `searchNearbyParkingLotsByLocationQuery(locationQuery, options)`
## Modu Parking status
Issue #135 mentioned connecting 모두의주차장 if possible. This package does **not** scrape or call an unofficial Modu Parking surface. Add it later only if there is an approved, stable, and legally usable API contract.

View file

@ -0,0 +1,32 @@
{
"name": "parking-lot-search",
"version": "0.1.0",
"description": "Official Data.go.kr based public parking lot lookup for Korean location queries",
"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",
"parking",
"public-parking",
"data-go-kr"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,301 @@
const {
extractAddressHint,
normalizeAnchorPanel,
normalizeParkingLotRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates
} = require("./parse");
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
const OFFICIAL_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
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/146.0.0.0 Safari/537.36"
};
const DEFAULT_PANEL_HEADERS = {
...DEFAULT_BROWSER_HEADERS,
accept: "application/json, text/plain, */*",
appVersion: "6.6.0",
origin: "https://place.map.kakao.com",
pf: "PC",
referer: "https://place.map.kakao.com/"
};
const DEFAULT_JSON_HEADERS = {
accept: "application/json, text/plain;q=0.9,*/*;q=0.8",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent": DEFAULT_BROWSER_HEADERS["user-agent"]
};
async function request(url, options = {}, responseType = "json") {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const headerSet = responseType === "json" ? options.headerSet || DEFAULT_JSON_HEADERS : options.headerSet || DEFAULT_BROWSER_HEADERS;
const response = await fetchImpl(url, {
headers: {
...headerSet,
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
const error = new Error(`Request failed with ${response.status} for ${url}`);
error.status = response.status;
error.url = url;
throw error;
}
return responseType === "json" ? response.json() : response.text();
}
function normalizeBoundedInteger(value, fallback, label, min, max) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
throw new Error(`${label} must be between ${min} and ${max}.`);
}
return parsed;
}
function normalizeBoolean(value, fallback = true) {
if (value === undefined || value === null || value === "") {
return fallback;
}
if (typeof value === "boolean") {
return value;
}
return !["0", "false", "n", "no", "민영포함"].includes(String(value).trim().toLowerCase());
}
function resolveProxyBaseUrl(options = {}) {
return options.proxyBaseUrl || process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL;
}
function resolveApiKey(options = {}) {
return options.apiKey || options.serviceKey || process.env.DATA_GO_KR_API_KEY || null;
}
function useDirectApi(options = {}) {
return options.useDirectApi || !!(options.apiKey || options.serviceKey);
}
function buildOfficialParkingLotApiUrl(options = {}) {
const serviceKey = options.serviceKey || options.apiKey;
if (!serviceKey) {
throw new Error("DATA_GO_KR_API_KEY or options.apiKey is required for direct official parking API lookups.");
}
const pageNo = normalizeBoundedInteger(options.pageNo, 1, "pageNo", 1, 100000);
const numOfRows = normalizeBoundedInteger(options.numOfRows, 1000, "numOfRows", 1, 1000);
const url = new URL(options.apiBaseUrl || OFFICIAL_API_URL);
url.searchParams.set("serviceKey", serviceKey);
url.searchParams.set("pageNo", String(pageNo));
url.searchParams.set("numOfRows", String(numOfRows));
url.searchParams.set("type", "json");
if (normalizeBoolean(options.publicOnly, true)) {
url.searchParams.set("prkplceSe", "공영");
}
if (options.parkingType) {
url.searchParams.set("prkplceType", String(options.parkingType).trim());
}
const addressHint = String(options.addressHint || options.address_hint || "").trim();
if (addressHint) {
url.searchParams.set(options.addressField || "rdnmadr", addressHint);
}
return url.toString();
}
async function fetchOfficialParkingLots(options = {}) {
const url = buildOfficialParkingLotApiUrl(options);
return request(url, options, "json");
}
async function fetchProxyParkingLots(options = {}) {
const base = resolveProxyBaseUrl(options);
const url = new URL(`${base}/v1/parking-lots/search`);
url.searchParams.set("latitude", String(options.latitude));
url.searchParams.set("longitude", String(options.longitude));
url.searchParams.set("limit", String(options.limit));
url.searchParams.set("radius", String(options.radius));
url.searchParams.set("public_only", String(options.publicOnly));
if (options.addressHint) {
url.searchParams.set("address_hint", options.addressHint);
}
if (options.parkingType) {
url.searchParams.set("parking_type", options.parkingType);
}
if (options.numOfRows) {
url.searchParams.set("num_of_rows", String(options.numOfRows));
}
if (options.maxPages) {
url.searchParams.set("max_pages", String(options.maxPages));
}
return request(url.toString(), options, "json");
}
async function fetchSearchResults(query, options = {}) {
const url = new URL(SEARCH_VIEW_URL);
url.searchParams.set("q", String(query || "").trim());
return request(url.toString(), options, "text");
}
async function fetchPlacePanel(confirmId, options = {}) {
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
}
function isRecoverablePlacePanelError(error) {
const status = Number(error?.status);
return Number.isInteger(status) && status >= 400 && status < 600;
}
async function resolveAnchor(locationQuery, options = {}) {
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
for (const candidate of rankedCandidates) {
let anchorPanel;
try {
anchorPanel = await fetchPlacePanel(candidate.id, options);
} catch (error) {
if (isRecoverablePlacePanelError(error)) {
continue;
}
throw error;
}
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
return { anchor, anchorCandidates: rankedCandidates };
}
}
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
}
async function searchNearbyParkingLotsByCoordinates(options = {}) {
const latitude = Number(options.latitude);
const longitude = Number(options.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceMeters, 2000, "radius", 1, 50000);
const publicOnly = normalizeBoolean(options.publicOnly ?? options.public_only, true);
const addressHint = String(options.addressHint || options.address_hint || "").trim() || null;
const apiKey = resolveApiKey(options);
if (!apiKey && !useDirectApi(options)) {
return fetchProxyParkingLots({
...options,
latitude,
longitude,
limit,
radius,
publicOnly,
addressHint
});
}
const payload = await fetchOfficialParkingLots({
...options,
serviceKey: apiKey,
addressHint,
publicOnly
});
const allItems = normalizeParkingLotRows(payload, { latitude, longitude }, { radius, publicOnly });
return {
anchor: {
name: options.anchorName || "입력 좌표",
address: options.anchorAddress || addressHint,
latitude,
longitude
},
items: allItems.slice(0, limit),
meta: {
total: allItems.length,
limit,
radius,
publicOnly,
addressHint,
source: "data.go.kr"
}
};
}
async function searchNearbyParkingLotsByLocationQuery(locationQuery, options = {}) {
const query = String(locationQuery || "").trim();
if (!query) {
throw new Error("locationQuery is required.");
}
const coordinateQuery = parseCoordinateQuery(query);
if (coordinateQuery) {
return searchNearbyParkingLotsByCoordinates({
...options,
...coordinateQuery,
anchorName: query
});
}
const { anchor, anchorCandidates } = await resolveAnchor(query, options);
const addressHint = options.addressHint || options.address_hint || extractAddressHint(anchor.address);
const result = await searchNearbyParkingLotsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude,
addressHint,
anchorName: anchor.name,
anchorAddress: anchor.address
});
return {
...result,
anchor,
anchorCandidates,
meta: {
...result.meta,
resolvedQuery: query,
addressHint
}
};
}
module.exports = {
DEFAULT_PROXY_BASE_URL,
OFFICIAL_API_URL,
PLACE_PANEL_URL_BASE,
SEARCH_VIEW_URL,
buildOfficialParkingLotApiUrl,
extractAddressHint,
fetchOfficialParkingLots,
fetchPlacePanel,
fetchProxyParkingLots,
fetchSearchResults,
normalizeAnchorPanel,
normalizeParkingLotRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates,
searchNearbyParkingLotsByCoordinates,
searchNearbyParkingLotsByLocationQuery
};

View file

@ -0,0 +1,339 @@
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
const TAG_PATTERN = /<[^>]+>/g;
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
const ANCHOR_STATION_PATTERN = /(역|기차역|전철역|지하철역|환승역)$/u;
const ANCHOR_CATEGORY_PATTERN =
/(기차역|전철역|지하철역|역사|광장|공원|거리|테마거리|관광명소|랜드마크|먹자골목|교차로|주차장|정류장|환승센터)/u;
const PARKING_PATTERN = /(주차장|공영주차장|민영주차장|주차타워)/u;
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripTags(value) {
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "");
}
function extractAttribute(fragment, name) {
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
return match ? decodeHtml(match[1]).trim() : "";
}
function extractInnerText(fragment, className) {
const match = fragment.match(
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
);
return match ? stripTags(match[1]) : "";
}
function parseSearchResultsHtml(html) {
const items = [];
let match;
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
const fragment = match[1];
const id = extractAttribute(fragment, "data-id");
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
if (!id || !name) {
continue;
}
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
.map((entry) => stripTags(entry[1]))
.filter(Boolean);
items.push({
id,
name,
category: extractInnerText(fragment, "txt_ginfo"),
address: addressMatches.at(-1) || "",
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone") || null
});
}
return items;
}
function scoreAnchorCandidate(query, item) {
const normalizedQuery = normalizeText(query);
const normalizedName = normalizeText(item.name);
const normalizedAddress = normalizeText(item.address);
const normalizedCategory = normalizeText(item.category);
let score = 0;
if (!normalizedQuery) {
return score;
}
if (normalizedName === normalizedQuery) {
score += 1000;
}
if (normalizedName === `${normalizedQuery}` || normalizedName === normalizedQuery.replace(/역$/u, "")) {
score += 950;
}
if (normalizedName.startsWith(normalizedQuery)) {
score += 800;
}
if (normalizedName.includes(normalizedQuery)) {
score += 600;
}
if (normalizedAddress.includes(normalizedQuery)) {
score += 120;
}
if (ANCHOR_STATION_PATTERN.test(item.name) || ANCHOR_CATEGORY_PATTERN.test(item.category)) {
score += 250;
}
if (PARKING_PATTERN.test(`${item.name} ${item.category}`)) {
score -= 100;
}
if (!/^\d+$/.test(String(item.id || ""))) {
score -= 500;
}
if (normalizedCategory.includes("기차역") || normalizedCategory.includes("전철역")) {
score += 80;
}
return score;
}
function rankAnchorCandidates(query, items) {
return [...(items || [])].sort((left, right) => {
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.name.localeCompare(right.name, "ko");
});
}
function normalizeAnchorPanel(panel, searchItem = {}) {
const summary = panel.summary || {};
return {
id: String(summary.confirm_id || searchItem.id || ""),
name: summary.name || searchItem.name || "",
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
address: summary.address?.disp || searchItem.address || "",
phone: summary.phone_numbers?.[0]?.tel || searchItem.phone || null,
latitude: toNumber(summary.point?.lat),
longitude: toNumber(summary.point?.lon),
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
function parseCoordinateQuery(locationQuery) {
const match = String(locationQuery || "")
.trim()
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
if (!match) {
return null;
}
return {
latitude: Number(match[1]),
longitude: Number(match[2])
};
}
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(String(value).replace(/,/g, ""));
return Number.isFinite(parsed) ? parsed : null;
}
function toBooleanYesNo(value) {
return String(value || "").trim().toUpperCase() === "Y";
}
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
const earthRadiusMeters = 6371008.8;
const toRadians = (value) => (value * Math.PI) / 180;
const deltaLatitude = toRadians(latitudeB - latitudeA);
const deltaLongitude = toRadians(longitudeB - longitudeA);
const originLatitude = toRadians(latitudeA);
const targetLatitude = toRadians(latitudeB);
const value =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function buildMapUrl(name, latitude, longitude) {
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
}
function getParkingItems(payload) {
if (Array.isArray(payload)) {
return payload;
}
const items = payload?.response?.body?.items ?? payload?.body?.items ?? payload?.items ?? [];
if (Array.isArray(items)) {
return items;
}
if (Array.isArray(items.item)) {
return items.item;
}
if (items.item && typeof items.item === "object") {
return [items.item];
}
return [];
}
function isPublicParking(row, publicOnly) {
if (!publicOnly) {
return true;
}
return String(row.prkplceSe || row["주차장구분"] || "").trim() === "공영";
}
function normalizeParkingLotRows(payload, origin, options = {}) {
const latitude = Number(origin?.latitude);
const longitude = Number(origin?.longitude);
const publicOnly = options.publicOnly !== false;
const maxDistanceMeters = Number.isFinite(Number(options.radius ?? options.maxDistanceMeters))
? Number(options.radius ?? options.maxDistanceMeters)
: null;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("normalizeParkingLotRows requires finite origin coordinates.");
}
const items = getParkingItems(payload)
.filter((row) => isPublicParking(row, publicOnly))
.map((row) => {
const itemLatitude = toNumber(row.latitude ?? row["위도"]);
const itemLongitude = toNumber(row.longitude ?? row["경도"]);
if (!Number.isFinite(itemLatitude) || !Number.isFinite(itemLongitude)) {
return null;
}
const name = String(row.prkplceNm ?? row["주차장명"] ?? "").trim();
const roadAddress = String(row.rdnmadr ?? row["소재지도로명주소"] ?? "").trim();
const lotAddress = String(row.lnmadr ?? row["소재지지번주소"] ?? "").trim();
const distanceMeters = haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude);
return {
id: String(row.prkplceNo ?? row["주차장관리번호"] ?? "").trim(),
name,
category: String(row.prkplceSe ?? row["주차장구분"] ?? "").trim() || null,
type: String(row.prkplceType ?? row["주차장유형"] ?? "").trim() || null,
address: roadAddress || lotAddress || null,
roadAddress: roadAddress || null,
lotAddress: lotAddress || null,
latitude: itemLatitude,
longitude: itemLongitude,
distanceMeters,
capacity: toNumber(row.prkcmprt ?? row["주차구획수"]),
grade: String(row.feedingSe ?? row["급지구분"] ?? "").trim() || null,
alternateDayEnforcement: String(row.enforceSe ?? row["부제시행구분"] ?? "").trim() || null,
operatingDays: String(row.operDay ?? row["운영요일"] ?? "").trim() || null,
weekday: {
open: String(row.weekdayOperOpenHhmm ?? row["평일운영시작시각"] ?? "").trim() || null,
close: String(row.weekdayOperColseHhmm ?? row["평일운영종료시각"] ?? "").trim() || null
},
saturday: {
open: String(row.satOperOperOpenHhmm ?? row["토요일운영시작시각"] ?? "").trim() || null,
close: String(row.satOperCloseHhmm ?? row["토요일운영종료시각"] ?? "").trim() || null
},
holiday: {
open: String(row.holidayOperOpenHhmm ?? row["공휴일운영시작시각"] ?? "").trim() || null,
close: String(row.holidayCloseOpenHhmm ?? row["공휴일운영종료시각"] ?? "").trim() || null
},
feeInfo: String(row.parkingchrgeInfo ?? row["요금정보"] ?? "").trim() || null,
basicTime: toNumber(row.basicTime ?? row["주차기본시간"]),
basicCharge: toNumber(row.basicCharge ?? row["주차기본요금"]),
additionalUnitTime: toNumber(row.addUnitTime ?? row["추가단위시간"]),
additionalUnitCharge: toNumber(row.addUnitCharge ?? row["추가단위요금"]),
dailyTicketTime: toNumber(row.dayCmmtktAdjTime ?? row["1일주차권요금적용시간"]),
dailyTicketCharge: toNumber(row.dayCmmtkt ?? row["1일주차권요금"]),
monthlyTicketCharge: toNumber(row.monthCmmtkt ?? row["월정기권요금"]),
paymentMethods: String(row.metpay ?? row["결제방법"] ?? "").trim() || null,
notes: String(row.spcmnt ?? row["특기사항"] ?? "").trim() || null,
managementAgency: String(row.institutionNm ?? row["관리기관명"] ?? "").trim() || null,
phone: String(row.phoneNumber ?? row["전화번호"] ?? "").trim() || null,
hasAccessibleParking: toBooleanYesNo(row.pwdbsPpkZoneYn ?? row["장애인전용주차구역보유여부"]),
referenceDate: String(row.referenceDate ?? row["데이터기준일자"] ?? "").trim() || null,
providerCode: String(row.instt_code ?? row["제공기관코드"] ?? "").trim() || null,
providerName: String(row.instt_nm ?? row["제공기관기관명"] ?? row["제공기관명"] ?? "").trim() || null,
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
};
})
.filter(Boolean)
.filter((item) => (maxDistanceMeters === null ? true : item.distanceMeters <= maxDistanceMeters))
.sort((left, right) => {
if (left.distanceMeters !== right.distanceMeters) {
return left.distanceMeters - right.distanceMeters;
}
return String(left.name || "").localeCompare(String(right.name || ""), "ko");
});
const dedupedItems = [];
const seen = new Set();
for (const item of items) {
const key = [item.id, item.name, item.address, item.latitude, item.longitude].join("::");
if (seen.has(key)) {
continue;
}
seen.add(key);
dedupedItems.push(item);
}
return dedupedItems;
}
function extractAddressHint(address) {
const parts = String(address || "").trim().split(/\s+/u).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]} ${parts[1]}`;
}
return parts[0] || null;
}
module.exports = {
buildMapUrl,
extractAddressHint,
getParkingItems,
haversineDistanceMeters,
normalizeAnchorPanel,
normalizeParkingLotRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates,
toNumber
};

View file

@ -0,0 +1,13 @@
{
"summary": {
"confirm_id": "1001",
"name": "광화문",
"address": {
"disp": "서울특별시 종로구 세종대로 172"
},
"point": {
"lat": 37.57103,
"lon": 126.97679
}
}
}

View file

@ -0,0 +1,7 @@
<ul>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_ginfo">역사유적지</span>
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
</li>
</ul>

View file

@ -0,0 +1,94 @@
{
"response": {
"header": {
"resultCode": "00",
"resultMsg": "NORMAL_SERVICE"
},
"body": {
"pageNo": 1,
"numOfRows": 4,
"totalCount": 4,
"items": [
{
"prkplceNo": "111-2-000001",
"prkplceNm": "종로구청 공영주차장",
"prkplceSe": "공영",
"prkplceType": "노외",
"rdnmadr": "서울특별시 종로구 삼봉로 43",
"lnmadr": "서울특별시 종로구 수송동 146-2",
"prkcmprt": "50",
"feedingSe": "1",
"enforceSe": "미시행",
"operDay": "평일+토요일+공휴일",
"weekdayOperOpenHhmm": "00:00",
"weekdayOperColseHhmm": "23:59",
"satOperOperOpenHhmm": "00:00",
"satOperCloseHhmm": "23:59",
"holidayOperOpenHhmm": "00:00",
"holidayCloseOpenHhmm": "23:59",
"parkingchrgeInfo": "유료",
"basicTime": "30",
"basicCharge": "1000",
"addUnitTime": "10",
"addUnitCharge": "500",
"dayCmmtktAdjTime": "24",
"dayCmmtkt": "10000",
"monthCmmtkt": "100000",
"metpay": "신용카드",
"spcmnt": "경차 할인",
"institutionNm": "서울특별시 종로구청",
"phoneNumber": "02-2148-0000",
"latitude": "37.57320",
"longitude": "126.97810",
"pwdbsPpkZoneYn": "Y",
"referenceDate": "2026-03-01",
"instt_code": "3000000",
"instt_nm": "서울특별시 종로구"
},
{
"prkplceNo": "111-2-000002",
"prkplceNm": "세종로 민영주차장",
"prkplceSe": "민영",
"prkplceType": "노외",
"rdnmadr": "서울특별시 종로구 세종대로 170",
"prkcmprt": "100",
"parkingchrgeInfo": "유료",
"latitude": "37.573714",
"longitude": "126.978339",
"referenceDate": "2026-03-01"
},
{
"prkplceNo": "111-2-000003",
"prkplceNm": "광화문광장 공영주차장",
"prkplceSe": "공영",
"prkplceType": "노상",
"rdnmadr": "서울특별시 종로구 세종대로 172",
"lnmadr": "서울특별시 종로구 세종로 1-68",
"prkcmprt": "20",
"operDay": "평일+토요일+공휴일",
"weekdayOperOpenHhmm": "09:00",
"weekdayOperColseHhmm": "21:00",
"parkingchrgeInfo": "무료",
"basicTime": "0",
"basicCharge": "0",
"institutionNm": "서울특별시 종로구청",
"latitude": "37.57375",
"longitude": "126.97836",
"pwdbsPpkZoneYn": "N",
"referenceDate": "2026-03-01",
"instt_nm": "서울특별시 종로구"
},
{
"prkplceNo": "111-2-000004",
"prkplceNm": "좌표없는 공영주차장",
"prkplceSe": "공영",
"prkplceType": "노외",
"rdnmadr": "서울특별시 종로구 새문안로 1",
"latitude": "",
"longitude": "",
"referenceDate": "2026-03-01"
}
]
}
}
}

View file

@ -0,0 +1,206 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
DEFAULT_PROXY_BASE_URL,
buildOfficialParkingLotApiUrl,
normalizeParkingLotRows,
parseCoordinateQuery,
searchNearbyParkingLotsByCoordinates,
searchNearbyParkingLotsByLocationQuery
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const parkingApiResponse = JSON.parse(fs.readFileSync(path.join(fixturesDir, "parking-api-response.json"), "utf8"));
const ORIGIN = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
latitude: 37.573713,
longitude: 126.978338
});
assert.equal(parseCoordinateQuery("광화문"), null);
});
test("buildOfficialParkingLotApiUrl targets the standard public parking API with safe filters", () => {
const url = new URL(buildOfficialParkingLotApiUrl({
serviceKey: "data-key",
pageNo: 2,
numOfRows: 500,
addressHint: "서울특별시 종로구",
parkingType: "노외",
publicOnly: true
}));
assert.equal(url.origin + url.pathname, "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
assert.equal(url.searchParams.get("serviceKey"), "data-key");
assert.equal(url.searchParams.get("type"), "json");
assert.equal(url.searchParams.get("pageNo"), "2");
assert.equal(url.searchParams.get("numOfRows"), "500");
assert.equal(url.searchParams.get("prkplceSe"), "공영");
assert.equal(url.searchParams.get("prkplceType"), "노외");
assert.equal(url.searchParams.get("rdnmadr"), "서울특별시 종로구");
});
test("normalizeParkingLotRows keeps public parking metadata and sorts by distance", () => {
const items = normalizeParkingLotRows(parkingApiResponse, ORIGIN);
assert.equal(items.length, 2);
assert.deepEqual(items.map((item) => [item.id, item.name, item.category, item.type]), [
["111-2-000003", "광화문광장 공영주차장", "공영", "노상"],
["111-2-000001", "종로구청 공영주차장", "공영", "노외"]
]);
assert.equal(items[0].feeInfo, "무료");
assert.equal(items[0].capacity, 20);
assert.equal(items[0].weekday.open, "09:00");
assert.equal(items[0].weekday.close, "21:00");
assert.equal(items[0].hasAccessibleParking, false);
assert.equal(items[1].basicCharge, 1000);
assert.equal(items[1].hasAccessibleParking, true);
assert.equal(
items[0].mapUrl,
"https://map.kakao.com/link/map/%EA%B4%91%ED%99%94%EB%AC%B8%EA%B4%91%EC%9E%A5%20%EA%B3%B5%EC%98%81%EC%A3%BC%EC%B0%A8%EC%9E%A5,37.57375,126.97836"
);
});
test("normalizeParkingLotRows can include private parking when requested", () => {
const items = normalizeParkingLotRows(parkingApiResponse, ORIGIN, { publicOnly: false });
assert.equal(items.length, 3);
assert.equal(items[0].name, "세종로 민영주차장");
assert.equal(items[0].category, "민영");
});
test("searchNearbyParkingLotsByCoordinates uses the official API directly when apiKey is provided", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return makeResponse(parkingApiResponse);
};
const result = await searchNearbyParkingLotsByCoordinates({
...ORIGIN,
limit: 1,
radius: 500,
addressHint: "서울특별시 종로구",
apiKey: "data-key",
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].name, "광화문광장 공영주차장");
assert.equal(result.meta.total, 2);
assert.equal(result.meta.source, "data.go.kr");
assert.match(calls[0], /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
assert.match(calls[0], /rdnmadr=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C\+%EC%A2%85%EB%A1%9C%EA%B5%AC/);
});
test("searchNearbyParkingLotsByCoordinates uses k-skill-proxy by default", async () => {
const calls = [];
const fetchImpl = async (url) => {
calls.push(String(url));
return makeResponse({
anchor: { ...ORIGIN, name: "입력 좌표" },
items: normalizeParkingLotRows(parkingApiResponse, ORIGIN),
meta: { total: 2, limit: 2, source: "k-skill-proxy" }
});
};
const result = await searchNearbyParkingLotsByCoordinates({
...ORIGIN,
limit: 2,
addressHint: "서울특별시 종로구",
fetchImpl
});
assert.equal(result.items.length, 2);
assert.equal(result.meta.source, "k-skill-proxy");
const url = new URL(calls[0]);
assert.equal(url.origin, DEFAULT_PROXY_BASE_URL);
assert.equal(url.pathname, "/v1/parking-lots/search");
assert.equal(url.searchParams.get("latitude"), String(ORIGIN.latitude));
assert.equal(url.searchParams.get("longitude"), String(ORIGIN.longitude));
assert.equal(url.searchParams.get("address_hint"), "서울특별시 종로구");
});
test("searchNearbyParkingLotsByLocationQuery resolves a Kakao anchor and derives an address hint", async () => {
const calls = [];
const fetchImpl = async (url) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved.startsWith("http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
const urlObject = new URL(resolved);
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
return makeResponse(parkingApiResponse);
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
limit: 2,
apiKey: "data-key",
fetchImpl
});
assert.equal(result.anchor.name, "광화문");
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
assert.equal(result.items.length, 2);
assert.deepEqual(calls.slice(0, 2), [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001"
]);
});
test("searchNearbyParkingLotsByCoordinates validates inputs", async () => {
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ latitude: "x", longitude: 126.9 }),
/latitude and longitude must be finite numbers/
);
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ ...ORIGIN, limit: 0 }),
/limit must be between 1 and 50/
);
await assert.rejects(
searchNearbyParkingLotsByCoordinates({ ...ORIGIN, radius: 0 }),
/radius must be between 1 and 50000/
);
});
function makeResponse(body, contentType = "application/json;charset=UTF-8") {
return {
ok: true,
status: 200,
headers: {
get(name) {
if (String(name).toLowerCase() === "content-type") {
return contentType;
}
return null;
}
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
async json() {
return typeof body === "string" ? JSON.parse(body) : body;
}
};
}

106
parking-lot-search/SKILL.md Normal file
View file

@ -0,0 +1,106 @@
---
name: parking-lot-search
description: Use when the user asks for nearby parking lots or 근처 주차장/공영주차장. Always ask the user's current location first, then use official Data.go.kr public parking data through k-skill-proxy or the parking-lot-search package.
license: MIT
metadata:
category: convenience
locale: ko-KR
phase: v1
---
# Parking Lot Search
## What this skill does
유저가 알려준 현재 위치를 기준으로 **근처 공영주차장** 을 찾는다.
- 위치는 자동 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 기본값은 `공영` 주차장만 보여준다.
- 공식 `전국주차장정보표준데이터` Open API를 사용한다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡은 뒤, 공식 주차장 데이터에서 거리순으로 정리한다.
- 실시간 만차/잔여면/예약 여부는 공식 표준데이터에 없으므로 확정해서 말하지 않는다.
## When to use
- "근처 주차장 찾아줘"
- "광화문 주변 공영주차장 어디 있어?"
- "서울역 근처 무료 주차장 있어?"
- "지금 여기서 가까운 공영주차장 지도 링크 줘"
## Mandatory first question
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
권장 질문:
`현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공영주차장을 찾아볼게요.`
위치가 애매하면:
`가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
## Official surfaces
- 표준데이터 안내: `https://www.data.go.kr/data/15012896/standard.do`
- Open API 안내: `https://www.data.go.kr/data/15012896/openapi.do`
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
- k-skill proxy: `/v1/parking-lots/search`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Workflow
1. 유저에게 반드시 현재 위치를 묻는다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보한다.
3. anchor 주소에서 `시도 + 시군구` address hint를 만든다.
4. k-skill-proxy `/v1/parking-lots/search` 또는 `parking-lot-search` 패키지로 공식 주차장 데이터를 조회한다.
5. 보통 3~5개만 짧게 정리하고, 지도 링크를 같이 준다.
6. 요금/운영시간은 데이터 기준일자와 함께 안내하고, 실시간 현황이 아님을 밝힌다.
## Responding
결과는 보통 아래 필드를 포함해 짧게 정리한다.
- 주차장명
- 거리
- 공영/민영 및 노상/노외/부설 유형
- 주소
- 운영요일/운영시간
- 요금정보, 기본요금, 추가요금
- 주차구획수
- 전화번호/관리기관
- 데이터기준일자
- 지도 링크
## Node.js example
```js
const { searchNearbyParkingLotsByLocationQuery } = require("parking-lot-search");
async function main() {
const result = await searchNearbyParkingLotsByLocationQuery("광화문", {
limit: 3,
radius: 1500
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 모두의주차장 status
Issue #135에서 모두의주차장 연동 가능성을 언급했지만, v1은 공식 공공데이터 기반으로 시작한다. 승인된 공식/파트너 API 계약이 확인되기 전에는 모두의주차장 비공식 API 호출이나 scraping을 하지 않는다.
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 공식 데이터 기반으로 최소 1개 이상 nearby parking lot을 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
- 가장 가까운 결과를 3~5개 이내로 정리했다.
- 실시간 잔여면/예약 가능 여부가 아님을 필요한 경우 명확히 했다.

View file

@ -1160,19 +1160,59 @@ test("repository docs advertise the coupang-product-search skill", () => {
assert.match(install, /--skill coupang-product-search/);
});
test("coupang-product-search skill and docs reference coupang-mcp", () => {
test("coupang-product-search skill and docs use retention-corp coupang_partners MCP layer", () => {
const skillPath = path.join(repoRoot, "coupang-product-search", "SKILL.md");
const wrapperPath = path.join(repoRoot, "coupang-product-search", "scripts", "coupang_partners_mcp.py");
const featureDoc = read(path.join("docs", "features", "coupang-product-search.md"));
const sources = read(path.join("docs", "sources.md"));
assert.ok(fs.existsSync(skillPath), "expected coupang-product-search/SKILL.md to exist");
assert.ok(fs.existsSync(wrapperPath), "expected retention-corp wrapper script 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, /retention-corp\/coupang_partners/);
assert.match(doc, /local:\/\/coupang-mcp/);
assert.match(doc, /coupang_partners_mcp\.py/);
assert.match(doc, /--repo-dir/);
assert.match(doc, /--no-clone/);
assert.match(doc, /--update/);
assert.match(doc, /coupang_partners_mcp\.py\s+tools/);
assert.match(doc, /coupang_partners_mcp\.py\s+init/);
assert.match(doc, /search_coupang_products/);
assert.match(doc, /로켓배송/);
assert.match(doc, /a\.retn\.kr\/v1\/public\/assist/);
assert.match(doc, /OPENCLAW_SHOPPING_/);
assert.match(doc, /(파트너스|어필리에이트|affiliate)/i);
assert.match(doc, /(hosted\s*fallback|호스티드\s*폴백|호스티드\s*fallback)/i);
assert.doesNotMatch(doc, /yuju777-coupang-mcp\.hf\.space\/mcp/);
assert.doesNotMatch(doc, /github\.com\/uju777\/coupang-mcp/);
}
assert.match(sources, /retention-corp\/coupang_partners/);
assert.match(sources, /a\.retn\.kr\/v1\/public\/assist/);
assert.doesNotMatch(sources, /yuju777-coupang-mcp\.hf\.space\/mcp/);
});
test("coupang-product-search docs drop non-allowlisted coupang-mcp-fallback and document openclaw-skill as the allowlisted hosted fallback client-id", () => {
// Direct probes against https://a.retn.kr/v1/public/assist on 2026-04-21 show that
// `X-OpenClaw-Client-Id: coupang-mcp-fallback` returns HTTP 403 ("Client is not
// allowlisted"), while `openclaw-skill` (the upstream default that ships with
// retention-corp/coupang_partners) returns HTTP 200. Until Retention Corp
// re-allowlists `coupang-mcp-fallback`, k-skill docs must not recommend it and
// must document `openclaw-skill` as the value the hosted fallback path uses.
const skill = read(path.join("coupang-product-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "coupang-product-search.md"));
const wrapper = read(path.join("coupang-product-search", "scripts", "coupang_partners_mcp.py"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [skill, featureDoc, wrapper, sources]) {
assert.doesNotMatch(doc, /coupang-mcp-fallback/);
}
for (const doc of [skill, featureDoc, wrapper]) {
assert.match(doc, /openclaw-skill/);
}
});
@ -2645,3 +2685,210 @@ test("hola-poke-yeoksam docs pin the verified remote MCP contract snapshot and p
assert.equal(fixture.enter_event_invalid_phone.error, "phone_format");
assert.match(fixture.enter_event_invalid_phone.message, /01012345678|010-1234-5678/);
});
test("repository docs advertise the library-book-search skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "library-book-search.md");
const skillPath = path.join(repoRoot, "library-book-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/library-book-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected library-book-search/SKILL.md to exist");
const featureDoc = read(path.join("docs", "features", "library-book-search.md"));
const skill = read(path.join("library-book-search", "SKILL.md"));
assert.match(skill, /^name: library-book-search$/m);
assert.match(readme, /\| 도서관 도서 조회 \|/);
assert.match(readme, /\[도서관 도서 조회 가이드\]\(docs\/features\/library-book-search\.md\)/);
assert.match(install, /--skill library-book-search/);
assert.match(install, /DATA4LIBRARY_AUTH_KEY/);
assert.match(sources, /data4library\.kr\/apiUtilization/);
assert.match(proxyDoc, /\/v1\/data4library\/book-search/);
assert.match(proxyDoc, /DATA4LIBRARY_AUTH_KEY/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /도서관 정보나루/);
assert.match(doc, /\/v1\/data4library\/book-search/);
assert.match(doc, /\/v1\/data4library\/book-detail/);
assert.match(doc, /\/v1\/data4library\/book-exists/);
assert.match(doc, /\/v1\/data4library\/libraries-by-book/);
assert.match(doc, /DATA4LIBRARY_AUTH_KEY.* /s);
assert.match(doc, /사용자.*시크릿.*없/);
}
});
test("repository docs advertise the korean-privacy-terms thin-wrapper skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-privacy-terms.md");
const skillPath = path.join(repoRoot, "korean-privacy-terms", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-privacy-terms.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-privacy-terms/SKILL.md to exist");
assert.match(readme, /\| 한국 개인정보처리방침·이용약관 자동 생성 \|/);
assert.match(
readme,
/\[한국 개인정보처리방침·이용약관 자동 생성 가이드\]\(docs\/features\/korean-privacy-terms\.md\)/,
);
assert.match(install, /--skill korean-privacy-terms/);
assert.match(roadmap, /한국 개인정보처리방침.이용약관 스킬 출시/);
assert.match(sources, /https:\/\/github\.com\/kimlawtech\/korean-privacy-terms/);
assert.match(sources, /Apache-2\.0/);
});
test("korean-privacy-terms skill is a thin wrapper that cites upstream and enforces a legal disclaimer", () => {
const skillPath = path.join(repoRoot, "korean-privacy-terms", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected korean-privacy-terms/SKILL.md to exist");
const skill = read(path.join("korean-privacy-terms", "SKILL.md"));
assert.match(skill, /^name: korean-privacy-terms$/m);
assert.match(skill, /^license: Apache-2\.0$/m);
assert.match(skill, /^description: .*개인정보처리방침.*이용약관.*$/m);
assert.match(
skill,
/\[?kimlawtech\/korean-privacy-terms\]?.*https:\/\/github\.com\/kimlawtech\/korean-privacy-terms/,
);
assert.match(skill, /Apache-2\.0/);
assert.match(skill, /참고용 초안/);
assert.match(skill, /법률 자문/);
assert.match(skill, /변호사 검토/);
assert.match(skill, /2026\.9\.11/);
assert.match(skill, /되묻/);
assert.match(skill, /개인정보처리방침/);
assert.match(skill, /이용약관/);
assert.match(skill, /쿠키 배너/);
assert.match(skill, /~\/\.claude\/skills\/korean-privacy-terms/);
assert.match(skill, /~\/\.agents\/skills\/korean-privacy-terms/);
assert.match(skill, /scripts\/install\.sh/);
assert.match(skill, /scripts\/upstream\.pin/);
assert.match(skill, /DISCLAIMER\.md/);
assert.match(skill, /## Notes/);
assert.doesNotMatch(skill, /AskUserQuestion/);
});
test("korean-privacy-terms preserves upstream NOTICE and DISCLAIMER for Apache-2.0 compliance", () => {
const noticePath = path.join(repoRoot, "korean-privacy-terms", "NOTICE");
const disclaimerPath = path.join(repoRoot, "korean-privacy-terms", "DISCLAIMER.md");
assert.ok(fs.existsSync(noticePath), "expected korean-privacy-terms/NOTICE to exist");
assert.ok(fs.existsSync(disclaimerPath), "expected korean-privacy-terms/DISCLAIMER.md to exist");
const notice = fs.readFileSync(noticePath, "utf8");
const disclaimer = fs.readFileSync(disclaimerPath, "utf8");
assert.match(notice, /korean-privacy-terms/);
assert.match(notice, /Copyright 2026 kimlawtech/);
assert.match(notice, /Apache License, Version 2\.0/);
assert.match(notice, /kimlawtech/i);
assert.match(disclaimer, /한국어/);
assert.match(disclaimer, /English/);
assert.match(disclaimer, /참고용 초안/);
assert.match(disclaimer, /reference drafts/i);
assert.match(disclaimer, /legal advice/i);
assert.match(disclaimer, /개인정보보호법/);
});
test("korean-privacy-terms ships an install.sh wrapper and a pinned upstream SHA", () => {
const pinPath = path.join(repoRoot, "korean-privacy-terms", "scripts", "upstream.pin");
const installPath = path.join(repoRoot, "korean-privacy-terms", "scripts", "install.sh");
assert.ok(fs.existsSync(pinPath), "expected korean-privacy-terms/scripts/upstream.pin to exist");
assert.ok(fs.existsSync(installPath), "expected korean-privacy-terms/scripts/install.sh to exist");
const pin = fs.readFileSync(pinPath, "utf8").trim();
assert.match(pin, /^[0-9a-f]{40}$/, "upstream.pin must contain a single 40-char git SHA");
assert.notStrictEqual(
pin,
"0".repeat(40),
"upstream.pin must not be a placeholder all-zero SHA",
);
assert.notStrictEqual(
pin,
"f".repeat(40),
"upstream.pin must not be a placeholder all-f SHA",
);
const install = fs.readFileSync(installPath, "utf8");
assert.match(install, /^#!\/(?:usr\/bin\/env bash|bin\/bash)/m, "install.sh must start with a bash shebang");
assert.match(install, /set -euo pipefail/, "install.sh must opt into strict bash mode");
assert.match(install, /~\/\.claude\/skills\/korean-privacy-terms/);
assert.match(install, /~\/\.agents\/skills\/korean-privacy-terms/);
assert.match(
install,
/https:\/\/github\.com\/kimlawtech\/korean-privacy-terms\.git/,
"install.sh must reference the full upstream clone URL",
);
assert.match(
install,
/git clone --filter=blob:none/,
"install.sh must perform a blobless git clone of the upstream repo",
);
assert.match(install, /upstream\.pin/);
const stat = fs.statSync(installPath);
assert.ok(
(stat.mode & 0o111) !== 0,
"install.sh must have the executable bit set on at least one of user/group/other",
);
});
test("korean-privacy-terms bundles the Apache-2.0 LICENSE per §4(a) redistribution requirement", () => {
const licensePath = path.join(repoRoot, "korean-privacy-terms", "LICENSE.upstream");
assert.ok(
fs.existsSync(licensePath),
"expected korean-privacy-terms/LICENSE.upstream to exist (Apache-2.0 §4(a) requires redistributors to give recipients a copy of this License)",
);
const license = fs.readFileSync(licensePath, "utf8");
assert.match(license, /Apache License/);
assert.match(license, /Version 2\.0, January 2004/);
assert.match(license, /http:\/\/www\.apache\.org\/licenses\/LICENSE-2\.0/);
assert.match(license, /TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION/);
assert.match(license, /Redistribution\. You may reproduce and distribute/);
assert.match(license, /END OF TERMS AND CONDITIONS/);
assert.match(license, /APPENDIX: How to apply the Apache License/);
assert.match(license, /Copyright 2026 kimlawtech/);
const skill = read(path.join("korean-privacy-terms", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "korean-privacy-terms.md"));
assert.match(
skill,
/LICENSE\.upstream/,
"SKILL.md Notes section must link to LICENSE.upstream so §4(a) is satisfied even before install.sh runs",
);
assert.match(
featureDoc,
/LICENSE\.upstream/,
"docs/features/korean-privacy-terms.md must reference LICENSE.upstream",
);
});
test("korean-privacy-terms feature doc documents the thin-wrapper install flow and legal disclaimer", () => {
const featureDoc = read(path.join("docs", "features", "korean-privacy-terms.md"));
assert.match(featureDoc, /kimlawtech\/korean-privacy-terms/);
assert.match(featureDoc, /Apache-2\.0/);
assert.match(featureDoc, /~\/\.claude\/skills\/korean-privacy-terms/);
assert.match(featureDoc, /~\/\.agents\/skills\/korean-privacy-terms/);
assert.match(featureDoc, /scripts\/install\.sh/);
assert.match(featureDoc, /scripts\/upstream\.pin/);
assert.match(featureDoc, /참고용 초안/);
assert.match(featureDoc, /법률 자문/);
assert.match(featureDoc, /변호사 검토/);
assert.match(featureDoc, /2026\.9\.11/);
assert.match(featureDoc, /Next\.js/);
});

View file

@ -0,0 +1,341 @@
import importlib.util
import json
import os
import pathlib
import subprocess
import sys
import tempfile
import unittest
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
WRAPPER_PATH = REPO_ROOT / "coupang-product-search" / "scripts" / "coupang_partners_mcp.py"
def load_wrapper_module():
spec = importlib.util.spec_from_file_location("coupang_partners_mcp", WRAPPER_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
class CoupangPartnersMcpWrapperTests(unittest.TestCase):
def test_defaults_to_retention_corp_repo_and_local_mcp_contract(self):
wrapper = load_wrapper_module()
self.assertEqual(wrapper.UPSTREAM_REPO_URL, "https://github.com/retention-corp/coupang_partners.git")
self.assertEqual(wrapper.DEFAULT_MCP_ENDPOINT, "local://coupang-mcp")
def test_passes_arguments_to_upstream_bin_without_network_when_repo_exists(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "coupang_partners"
bin_dir = repo_dir / "bin"
bin_dir.mkdir(parents=True)
upstream = bin_dir / "coupang_mcp.py"
upstream.write_text(
"#!/usr/bin/env python3\n"
"import json, sys\n"
"print(json.dumps({'argv': sys.argv[1:]}))\n",
encoding="utf-8",
)
upstream.chmod(0o755)
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
payload = json.loads(completed.stdout)
self.assertEqual(payload["argv"], ["tools"])
self.assertEqual(completed.stderr, "")
def test_sets_local_mcp_endpoint_for_upstream_by_default(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "coupang_partners"
bin_dir = repo_dir / "bin"
bin_dir.mkdir(parents=True)
upstream = bin_dir / "coupang_mcp.py"
upstream.write_text(
"#!/usr/bin/env python3\n"
"import json, os\n"
"print(json.dumps({'endpoint': os.environ.get('COUPANG_MCP_ENDPOINT')}))\n",
encoding="utf-8",
)
upstream.chmod(0o755)
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
payload = json.loads(completed.stdout)
self.assertEqual(payload["endpoint"], "local://coupang-mcp")
def test_preserves_explicit_mcp_endpoint_override_for_compatibility(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "coupang_partners"
bin_dir = repo_dir / "bin"
bin_dir.mkdir(parents=True)
upstream = bin_dir / "coupang_mcp.py"
upstream.write_text(
"#!/usr/bin/env python3\n"
"import json, os\n"
"print(json.dumps({'endpoint': os.environ.get('COUPANG_MCP_ENDPOINT')}))\n",
encoding="utf-8",
)
upstream.chmod(0o755)
env = {
**os.environ,
"COUPANG_MCP_ENDPOINT": "local://custom-coupang-mcp",
}
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
payload = json.loads(completed.stdout)
self.assertEqual(payload["endpoint"], "local://custom-coupang-mcp")
def test_propagates_upstream_nonzero_exit_code(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "coupang_partners"
bin_dir = repo_dir / "bin"
bin_dir.mkdir(parents=True)
upstream = bin_dir / "coupang_mcp.py"
upstream.write_text(
"#!/usr/bin/env python3\n"
"import sys\n"
"print('upstream failed', file=sys.stderr)\n"
"raise SystemExit(7)\n",
encoding="utf-8",
)
upstream.chmod(0o755)
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertEqual(completed.returncode, 7)
self.assertIn("upstream failed", completed.stderr)
def test_no_clone_reports_actionable_error_for_missing_upstream_checkout(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "missing"
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("retention-corp/coupang_partners", completed.stderr)
self.assertIn("git clone", completed.stderr)
def test_missing_command_guidance_includes_contract_init_command(self):
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self.assertEqual(completed.returncode, 2)
self.assertIn("tools", completed.stderr)
self.assertIn("init", completed.stderr)
self.assertIn("search <keyword>", completed.stderr)
def test_forwards_openclaw_shopping_env_vars_to_upstream(self):
with tempfile.TemporaryDirectory() as tmp:
repo_dir = pathlib.Path(tmp) / "coupang_partners"
bin_dir = repo_dir / "bin"
bin_dir.mkdir(parents=True)
upstream = bin_dir / "coupang_mcp.py"
upstream.write_text(
"#!/usr/bin/env python3\n"
"import json, os\n"
"keys = [\n"
" 'OPENCLAW_SHOPPING_CLIENT_ID',\n"
" 'OPENCLAW_SHOPPING_FORCE_HOSTED',\n"
" 'OPENCLAW_SHOPPING_BASE_URL',\n"
"]\n"
"print(json.dumps({k: os.environ.get(k) for k in keys}))\n",
encoding="utf-8",
)
upstream.chmod(0o755)
env = {
**os.environ,
"OPENCLAW_SHOPPING_CLIENT_ID": "openclaw-skill",
"OPENCLAW_SHOPPING_FORCE_HOSTED": "1",
"OPENCLAW_SHOPPING_BASE_URL": "https://staging.example.com",
}
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
str(repo_dir),
"--no-clone",
"tools",
],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
payload = json.loads(completed.stdout)
self.assertEqual(payload["OPENCLAW_SHOPPING_CLIENT_ID"], "openclaw-skill")
self.assertEqual(payload["OPENCLAW_SHOPPING_FORCE_HOSTED"], "1")
self.assertEqual(payload["OPENCLAW_SHOPPING_BASE_URL"], "https://staging.example.com")
def test_help_epilog_documents_credentialless_hosted_fallback(self):
completed = subprocess.run(
[sys.executable, str(WRAPPER_PATH), "--help"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
help_text = completed.stdout
self.assertIn("COUPANG_ACCESS_KEY", help_text)
self.assertIn("OPENCLAW_SHOPPING", help_text)
self.assertRegex(help_text, r"(hosted|호스티드|a\.retn\.kr)")
def test_help_epilog_drops_non_allowlisted_coupang_mcp_fallback_recommendation(self):
# Direct probes against https://a.retn.kr/v1/public/assist on 2026-04-21
# confirmed that `X-OpenClaw-Client-Id: coupang-mcp-fallback` returns
# HTTP 403 ("Client is not allowlisted"), while the upstream default
# `openclaw-skill` returns HTTP 200. The wrapper's --help must not
# recommend the dead value and must surface openclaw-skill so users
# understand the allowlisted hosted-fallback client id in play.
completed = subprocess.run(
[sys.executable, str(WRAPPER_PATH), "--help"],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
)
help_text = completed.stdout
self.assertNotIn("coupang-mcp-fallback", help_text)
self.assertIn("openclaw-skill", help_text)
@unittest.skipUnless(
os.getenv("K_SKILL_COUPANG_SMOKE") == "1",
"set K_SKILL_COUPANG_SMOKE=1 to run the live upstream smoke test",
)
class CoupangPartnersMcpHostedFallbackSmokeTests(unittest.TestCase):
"""Live upstream smoke test.
Opt-in via `K_SKILL_COUPANG_SMOKE=1` because this hits the real
`retention-corp/coupang_partners` checkout and the hosted backend at
`https://a.retn.kr`, both of which are outside CI's control. Verifies that
the credentialless hosted fallback path returns at least one result that
includes a Retention Corp short deeplink so the wrapper contract stays wired.
"""
def test_credentialless_search_returns_hosted_shortlink(self):
repo_dir = os.getenv(
"COUPANG_PARTNERS_REPO_DIR",
str(pathlib.Path.home() / ".cache/k-skill/coupang_partners"),
)
env = {
k: v
for k, v in os.environ.items()
if k not in {"COUPANG_ACCESS_KEY", "COUPANG_SECRET_KEY"}
}
completed = subprocess.run(
[
sys.executable,
str(WRAPPER_PATH),
"--repo-dir",
repo_dir,
"search",
"무선청소기",
],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
timeout=60,
)
self.assertEqual(
completed.returncode,
0,
msg=f"wrapper failed: stderr={completed.stderr}",
)
payload = json.loads(completed.stdout)
self.assertTrue(payload.get("ok"), msg=f"envelope not ok: {payload}")
# Accept either the hosted shortlink shape or a direct coupang affiliate
# link, since hosted fallback and local HMAC path surface slightly
# different URL shapes. At least one of them should be present.
serialized = json.dumps(payload, ensure_ascii=False)
self.assertRegex(
serialized,
r"(a\.retn\.kr/s/|link\.coupang\.com/)",
msg="expected at least one Coupang deeplink in response",
)
if __name__ == "__main__":
unittest.main()