Merge dev into main: new skills & proxy enhancements (#72)

* Remove client-side Seoul subway key setup

Route Seoul subway arrival lookups through k-skill-proxy so the
hosted proxy owns the Seoul Open Data upstream key and end users
only need the proxy base URL. Add proxy route coverage, update
skill/docs guidance, and align setup materials with the hosted
proxy flow used for fine dust.

Constraint: Must keep the proxy public, read-only, and dependency-free
Constraint: Must satisfy TDD-first verification and ship on feature/#35 targeting dev
Rejected: Add a separate client helper package | unnecessary extra layer for a single proxy route
Rejected: Keep SEOUL_OPEN_API_KEY as an end-user requirement | defeats the issue goal
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the Seoul subway proxy surface limited to station-arrival passthrough unless tests/docs expand the contract
Tested: npm run ci; local proxy runtime on 127.0.0.1:4120 for /health and /v1/seoul-subway/arrival on 2026-03-31 with an invalid upstream key
Not-tested: Live success response with a valid Seoul Open API key
Related: #35

* Prevent broken Seoul subway proxy defaults before hosted rollout

The Seoul subway proxy endpoint code is present locally, but the hosted public route is not live yet. This change turns the user-facing subway docs back into an explicit proxy configuration flow, replaces the misleading hosted default in setup examples, and keeps subway proxy examples on self-host/local URLs until rollout is verified.

Constraint: Hosted k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival is not live yet
Rejected: Keep the hosted Seoul subway URL as the default path | would send default users to a 404 route
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not restore a hosted Seoul subway default until the public proxy route is deployed and smoke-verified
Tested: node --test scripts/skill-docs.test.js; npm run ci; local proxy smoke on 127.0.0.1:4120 with stubbed Seoul upstream (GET /health, GET /v1/seoul-subway/arrival?stationName=강남)
Not-tested: Live hosted proxy smoke after deployment

* Reduce Seoul subway proxy upstream pressure with request caching

The public subway arrival route already normalized caller input but still re-fetched the Seoul upstream for every identical poll. This change adds a short-TTL cache keyed from the normalized subway query, annotates JSON responses with cache metadata, and locks the repeated-read collapse with a regression that proves alias/default normalization still reuses the cached result.

Constraint: The endpoint must stay public/no-auth while protecting a shared server-side SEOUL_OPEN_API_KEY from unnecessary repeated upstream hits
Rejected: Add a new shared cache abstraction for proxy metadata | unnecessary for this narrow fix and would enlarge the diff
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep subway cache keys tied to normalizeSeoulSubwayQuery output so alias/default query forms continue collapsing to one upstream request
Tested: node --test packages/k-skill-proxy/test/server.test.js
Tested: npm run ci
Tested: Local proxy runtime on 127.0.0.1:4120 with SEOUL_OPEN_API_KEY=test-seoul-key and stubbed fetch for /health plus repeated /v1/seoul-subway/arrival requests
Not-tested: Live hosted proxy rollout state

* Document OpenClaw support in the public compatibility list

The approved scope for issue #29 was narrowed to README support messaging,
so this change adds OpenClaw/ClawHub to the supported-client line and locks
that wording with a regression test in the docs suite.

Constraint: Issue #29 was approved as a README-only compatibility/docs change
Rejected: Broader install-doc updates | out of approved scope for this issue
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep OpenClaw support wording aligned with the supported-client README line and its docs regression test
Tested: Focused Node docs regression, npm run lint, npm run typecheck, npm run build, npm test
Not-tested: Live OpenClaw or ClawHub install flow (issue scope was documentation-only)

* Protect README client-support claims from ClawHub regressions

The README already advertises OpenClaw/ClawHub, but the docs
regression only matched OpenClaw. Tighten the assertion to the
exact supported-client fragment so a future edit cannot silently
remove ClawHub while keeping the issue verification command stable.

Constraint: PR #39 already publishes a verification command keyed to the current test name
Rejected: Rename the test to mention ClawHub explicitly | would drift from the published verification command
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this regression synchronized with the README supported-client line whenever that copy changes
Tested: node --test scripts/skill-docs.test.js --test-name-pattern 'README advertises OpenClaw among the supported coding agents'
Tested: npm run lint
Tested: npm run typecheck
Tested: npm run build
Tested: npm test
Tested: lsp diagnostics for scripts/skill-docs.test.js (0 errors)
Not-tested: N/A

* Make Coupang shopper research usable without pretending scraping is stable

Coupang blocks unattended shopper queries in this environment, so the new package focuses on the durable pieces we can ship honestly: official URL builders, browser-captured HTML parsers, and explicit automation probes. The docs and skill now explain the seller-API limitation, the verified anti-bot behavior, and the browser-capture fallback expected by callers.

Constraint: No general shopper Open API surfaced in Coupang's official developer docs

Constraint: Headless/direct retrieval is anti-bot blocked in verified local probes

Rejected: Bundle a scraping bypass or hidden browser dependency | violates repo policy and would over-claim reliability

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep probe/docs behavior aligned with fresh live verification before claiming unattended Coupang access works

Tested: npm run ci; live probeAutomation(query=생수) with direct fetch + Playwright-core browserFetchHtml; LSP diagnostics on changed JS/test files

Not-tested: Headed/manual browser sessions with a human-authenticated Coupang context

* Keep the Coupang workspace installable and its entrypoint honest

Review follow-up found two contract gaps in the first issue #36 rollout: fresh installs were missing the new workspace link in package-lock, and the package README documented parser helpers that were not exported from the package entrypoint. Refreshing the lockfile and re-exporting the parsers keeps npm ci and consumer imports aligned with the published API.

Constraint: npm ci must succeed from a clean checkout
Rejected: Narrow the README API list to only client helpers | would hide useful parsers that the package already ships and tests
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When adding a new workspace, refresh package-lock and verify the package entrypoint matches README public API claims
Tested: npm run ci; workspace coupang-product-search tests; LSP diagnostics on coupang-product-search JS/test files
Not-tested: published npm install from registry (local pack dry-run only)

* Keep Coupang anti-bot docs honest as probe results drift

A same-day PR rerun showed the published mobile probe snapshot was already stale, so the follow-up locks the 403/access-denied mobile path with regression coverage and softens the docs/skill contract to describe blocked outcome classes instead of one frozen signature. The published guidance now also explains that browser results only appear when browserFetchHtml is injected, matching what a clean checkout can actually reproduce.

Constraint: Coupang anti-bot responses vary by edge/challenge between reruns on the same day
Rejected: Freeze one exact mobile probe snapshot | same-day reruns already diverged
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Refresh Coupang anti-bot docs only with same-day live probe evidence, and prefer blocked outcome classes over a single exact signature when reruns disagree
Tested: npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch
Not-tested: Non-Chrome Playwright-core executables

* Keep the Coupang skill honest about clean-checkout browser probes

The approved follow-up already documented that clean-checkout probeAutomation runs leave browser unset unless a browser fetcher is injected, but the skill text itself had not made that null contract explicit. This commit adds a regression test that locks the browser-null wording across the published docs surfaces and updates the skill so the stated behavior matches the package implementation and clean-checkout reality.

Constraint: probeAutomation() only populates browser when browserFetchHtml is supplied by the caller
Rejected: Leave the skill wording implicit and rely on README examples alone | the skill could drift away from the shipped package contract again
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the README, feature doc, and skill aligned whenever the probeAutomation browser contract changes
Tested: node --test packages/coupang-product-search/test/index.test.js; npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch; codex exec review --uncommitted
Not-tested: Alternative browser engines beyond local Google Chrome for the manual browserFetchHtml probe

* Keep Coupang browser probe docs aligned with manual verification paths

The approved PR #40 follow-up still had one wording gap: the skill said when browser results appear, but it did not explicitly say those populated results come from an injected manual/external browserFetchHtml path. This change locks that contract with a failing regression first, then updates the skill text to match the already-shipped README/feature-doc guidance and runtime behavior.

Constraint: clean-checkout probeAutomation() must keep browser null unless browserFetchHtml is supplied
Rejected: change runtime browser probe behavior | approved follow-up was docs/test only and runtime contract is already correct
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: keep Coupang probe docs aligned with the shipped browserFetchHtml injection contract; do not imply built-in browser probing in clean checkouts
Tested: node --test packages/coupang-product-search/test/index.test.js; npm run lint; npm run typecheck; npm test; npm run ci; live probeAutomation("생수") with direct fetch + Playwright-core browser fetch; lsp diagnostics on changed files; architect review approved
Not-tested: alternate browser engines beyond local Google Chrome + playwright-core manual runner

* Route Korean law lookups through korean-law-mcp

Add a documentation-first korean-law-search skill and lock the repo docs around the rule that Korean law queries must go through korean-law-mcp rather than a new in-repo package.

The change updates install/setup/security guidance, publishes the new feature doc, and adds regression tests so future edits keep the LAW_OC + korean-law-mcp contract intact.

Constraint: Issue #41 requires korean-law-mcp for every Korean law lookup
Constraint: Must not add a new npm or python package in this repository
Rejected: Add a repo-local law package | violates the no-new-package requirement and duplicates upstream MCP work
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep Korean law lookup guidance pinned to korean-law-mcp unless issue requirements explicitly change
Tested: node --test scripts/skill-docs.test.js
Tested: npm install -g korean-law-mcp && korean-law list && korean-law help search_law
Tested: npm run ci
Tested: npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Not-tested: live search_law/get_law_text against a real LAW_OC credential

* Clarify Korean law setup modes to avoid credential confusion

A review found the new korean-law-search docs treated LAW_OC as an unconditional prerequisite even though the upstream remote MCP endpoint is configured separately from the local CLI/server path. This update makes the docs and regression tests mode-specific: local CLI/MCP uses LAW_OC, while the remote endpoint stays a korean-law-mcp-only url fallback without a user-supplied credential.

Constraint: Upstream korean-law-mcp uses LAW_OC on the local CLI/server path while the documented remote MCP endpoint is configured with url only
Rejected: Keep LAW_OC mandatory for every korean-law-mcp mode | contradicts upstream docs and reviewer evidence
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep README/setup/skill docs and regression tests aligned on the local-vs-remote korean-law-mcp contract
Tested: node --test scripts/skill-docs.test.js; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm run ci; lsp_diagnostics scripts/skill-docs.test.js
Not-tested: Live remote MCP endpoint connection against https://korean-law-mcp.fly.dev/mcp

* Record issue #41 follow-up after approved verification

The approved korean-law-search change was already present on feature/#41, so this follow-up records a fresh verification pass and updates the PR without introducing unnecessary code churn.

Constraint: Existing branch already contained the approved implementation
Rejected: Touch repo files without need | would create noise without improving behavior
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the korean-law-mcp-only + mode-specific LAW_OC contract aligned with upstream docs before changing these surfaces again
Tested: node --test scripts/skill-docs.test.js; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npm run ci
Not-tested: Live korean-law queries that require a real LAW_OC or remote endpoint session

* Keep the Korean law feature diff merge-ready

Verification uncovered trailing whitespace in the new korean-law feature guide when checking the branch diff. I added a regression to the skill docs suite and removed the whitespace so the approved documentation contract stays clean and reviewable.

Constraint: Preserve the existing korean-law-mcp + mode-specific LAW_OC contract without widening scope
Rejected: Leave the whitespace issue as-is | git diff --check stayed dirty on the branch
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep docs/features/korean-law-search.md free of trailing whitespace so diff hygiene stays enforced by the regression
Tested: node --test scripts/skill-docs.test.js; npx tsc --noEmit --pretty false --project /Users/jeffrey/Projects/k-skill/tsconfig.json; npm install -g korean-law-mcp && korean-law list && korean-law help search_law; npm run ci; git diff --check
Not-tested: Live credentialed LAW_OC search execution against the upstream API

* Keep Korean law skill guidance aligned with supported lookups

The approved issue #41 feature already worked, but the shipped skill text still under-described ordinance and interpretation lookups compared with the documented capability set. This follow-up tightens the skill copy and locks that contract with a doc regression so the branch stays merge-ready without changing the underlying korean-law-mcp routing rules.

Constraint: Must preserve the existing korean-law-mcp-only and mode-specific LAW_OC setup contract
Rejected: Leave the skill wording as-is | drift from the documented lookup surface would remain
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the skill examples and doc regression aligned whenever supported korean-law-mcp search surfaces change
Tested: node --test scripts/skill-docs.test.js
Not-tested: live LAW_OC-backed upstream API queries

* Keep Korean law completion guidance in sync with enforced lookups

The previous follow-up aligned the main skill examples, but the done-criteria text still left interpretation and ordinance routing implicit. This commit makes the completion checklist explicit and extends the doc regression so future edits cannot silently drop those lookup paths.

Constraint: Must stay within the existing issue #41 korean-law-mcp-only contract
Rejected: Leave the done checklist implicit | reviewers and future edits could drift from the enforced lookup set
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When the supported Korean-law lookup set changes, update the done checklist and regression together
Tested: node --test scripts/skill-docs.test.js
Not-tested: full CI before commit

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

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

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

* Keep used-car-price-search releasable after merge

Review feedback found that the new used-car workspace would not ship because it lacked a changeset, and the fallback install docs still omitted the runtime package. This follow-up adds regression coverage first, then restores both release and install-path coverage with the smallest possible diff.

Constraint: New publishable workspaces must be wired through Changesets to reach npm release automation
Constraint: Fallback install docs must list runtime packages users need when skill files are present but global packages are missing
Rejected: Fix only the changeset gap | would leave the documented fallback install path incomplete
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep used-car-price-search in both the fallback global install example and a Changesets entry whenever release wiring changes
Tested: node --test scripts/skill-docs.test.js; npx changeset status; live 10-query used-car run against SK direct at 2026-04-02T07:38:44.949Z; npm run ci; LSP diagnostics on used-car package files and scripts/skill-docs.test.js; architect verification
Not-tested: No additional live provider permutations beyond the verified 10-query smoke run

* Keep used-car verification docs resilient to live inventory churn

A fresh rerun showed the SK direct inventory total continues to move during the day, so the feature doc now records the verified smoke-run timestamp without freezing a brittle exact count. The regression suite was updated first so the docs stay variability-aware and diff-clean in future follow-ups.

Constraint: Live SK direct inventory totals change over time even when the parsing contract stays stable
Rejected: Keep documenting a fixed total from one smoke run | it immediately drifted and made the docs stale
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the used-car live verification note timestamped and variability-aware instead of pinning an exact inventory total
Tested: node --test scripts/skill-docs.test.js; git diff --check; npm run ci; npx changeset status; live 10-query run against https://www.skdirect.co.kr/tb at 2026-04-02T07:59:41.391Z; LSP diagnostics on scripts/skill-docs.test.js; architect verification
Not-tested: No additional content changes outside the used-car feature doc

* Keep used-car query summaries honest when results are limited

The review found that query-level stats were being computed from the
post-limit slice, so common searches like 아반떼 under-reported both
match counts and price ranges. This change locks the regression first,
then computes the full filtered set before slicing only the returned
items.

Constraint: PR #48 must preserve the existing API shape while fixing the review-blocking stats bug
Rejected: Expanding the response with separate limited/full summary fields | unnecessary API churn for a targeted bug fix
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Query-level summary and matchedCount must stay derived from the full filtered set, not the display limit slice
Tested: npm test --workspace used-car-price-search; npm run ci; git diff --check; npx changeset status; live SK direct smoke run on 2026-04-02T08:23:59Z; LSP diagnostics on used-car-price-search source and test files; architect verification APPROVED
Not-tested: limit=0 semantics remain unchanged and still coerce to the default limit
Related: PR #48

* Keep korean-law-search available during upstream outages (#45)

Issue #44 adds Beopmang as the documented fallback when the primary korean-law-mcp path is unavailable, and locks the new routing into the doc regression suite so future edits do not silently revert the policy.

Constraint: Existing guidance must still prefer korean-law-mcp and keep LAW_OC scoped to the local CLI/MCP path
Rejected: Add a repo-local Beopmang client package | issue only requires fallback registration, not a new implementation surface
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the primary-first rule intact; Beopmang is an outage fallback, not the default path
Tested: node --test scripts/skill-docs.test.js; npm run ci; korean-law list; python3 live Beopmang search smoke for 관세법
Not-tested: Live Beopmang MCP handshake from a GUI MCP client

* Replace coupang scraping package with coupang-mcp server integration

Drop the browser-based scraping package (packages/coupang-product-search)
and switch to the uju777/coupang-mcp MCP server for all Coupang product
searches. This removes the anti-bot workaround complexity and provides
8 ready-to-use tools via MCP Streamable HTTP with no API key required.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add disclaimer to used-car-price-search README

Clarify that the package queries SK렌터카 타고BUY public data with
no affiliation or sponsorship, and that ad/partnership inquiries
are welcome.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Refactor used-car-price-search into provider-based architecture

SK 타고BUY 전용 로직을 src/providers/sk-tagobuy.js로 분리하고,
공급자를 수평 확장할 수 있는 registry 구조로 전환한다.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Ship LCK analytics inside k-skill's managed release flow

Adapt jerjangmin's upstream lck-analytics skill/package into a new
workspace and skill pack, wire docs/install/release surfaces, and add
regression fixtures/tests plus script smoke coverage so the feature is
verifiable before publish.

Constraint: Upstream package is not published to npm yet
Constraint: Must preserve attribution to original source and author in shipped docs
Rejected: Keep the upstream lck-results install wording in k-skill | conflicts with repo workspace/package naming
Rejected: Ship only the npm package without the local skill scripts | issue explicitly requested the skill as well
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep Changesets as the only version-bump path for lck-analytics; do not hand-edit versions for release
Tested: node --test packages/lck-analytics/test/index.test.js scripts/skill-docs.test.js
Tested: npm run lint --workspace lck-analytics
Tested: npm test --workspace lck-analytics
Tested: live getLckSummary('2026-04-01', { team: '한화', includeStandings: true })
Tested: node lck-analytics/scripts/sync-oracle.js --csv lck-analytics/samples/oracle-lck-sample.csv --cache .tmp/lck-cache && node lck-analytics/scripts/build-match-report.js --date 2026-04-01 --team 한화 --cache .tmp/lck-cache && node lck-analytics/scripts/analyze-live-game.js --game game-1 --window packages/lck-analytics/test/fixtures/live-window-game-1.json --details packages/lck-analytics/test/fixtures/live-details-game-1.json --cache .tmp/lck-cache
Tested: npm run ci
Tested: npx tsc --noEmit --project /Users/jeffrey/Projects/k-skill/tsconfig.json
Not-tested: Riot live feed behavior for arbitrary future game ids outside the fixture-backed smoke path

* Enable policy-aware Korean spell checking from the official Nara surface

Add a skill guide and Python helper that use the approved old_speller HTML flow, chunk long text conservatively, and report original/suggestion/reason deltas. The docs also record the public-site limits, Cloudflare behavior, and non-commercial usage policy so agents do not overreach the free surface.

Constraint: Public site is HTML-only and may return 403 to non-browser clients
Constraint: Must not add new dependencies or high-volume crawling behavior
Rejected: Node fetch client | Cloudflare returned 403 in this environment
Rejected: Paid API integration | no public contract or credentials were available for this task
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep usage low-rate and non-commercial unless supplier-approved API terms are added
Tested: npm run lint
Tested: npm run typecheck
Tested: npm test
Tested: npm run build
Tested: python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json
Tested: python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json
Not-tested: Paid API/order flow
Not-tested: High-volume commercial workloads
Not-tested: Non-UTF-8 file inputs

* Preserve korean spell-check layout in corrected output

The Nara payload can collapse paragraph separators into normalized page text, so the helper now maps corrections back onto the original chunk before rebuilding corrected_text. The CLI also rejects non-positive --max-chars values, and regression tests cover both the layout-preservation path and invalid argument handling.

Constraint: Nara result pages can normalize blank lines and sentence spacing before exposing errInfo offsets
Rejected: Narrow docs away from file/Markdown proofreading | preserving original chunk separators keeps the documented workflow intact
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep multiline separator preservation tied to the original chunk whenever a suggestion only changes whitespace across collapsed boundaries
Tested: python3 -m unittest scripts.test_korean_spell_check; npm run lint; npm run typecheck; npm test; npm run build; python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json; python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json; python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n왠지 않되요.' --format json; python3 scripts/korean_spell_check.py --text 테스트 --max-chars 0 --format json
Not-tested: Live multi-page Nara payloads with separator-sensitive corrections across multiple returned pages

* Preserve file-proofreading layout in Korean spell check output

The spell-check helper now keeps exact blank-line runs and paragraph
indentation when chunking and reassembling file-style input, while still
allowing the official service's spacing corrections to flow through.
Regression coverage now locks the collapsed-layout, triple-blank-line,
and cross-boundary spacing cases that triggered the PR review.

Constraint: Official Nara/PNU payloads can collapse layout and sometimes merge corrections across preserved paragraph boundaries
Rejected: Narrow docs away from file-level proofreading | the existing feature scope explicitly supports file and Markdown checks
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve exact layout tokens only at original newline boundaries; do not reintroduce global strip/join normalization without real file-mode verification
Tested: npm run lint; npm run typecheck; npm test; npm run build; python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json; python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json; python3 scripts/korean_spell_check.py --file <tmpfile> --format json; python3 scripts/korean_spell_check.py --text 테스트 --max-chars 0 --format json
Not-tested: Live upstream behavior for extremely long leading/trailing-whitespace-only files
Related: PR #60

* Keep layout-preserving chunk splits from tripping on separator-boundary fixes

The follow-up layout preservation pass renamed the working unit variable, but one separator-length guard still referenced the old paragraph name. This commit finishes the refactor so exact-boundary paragraph chunks keep the preserved-separator logic reachable and the new regression coverage stays green.

Constraint: Must preserve the existing feature/#47 branch and PR #60 flow while fixing the approved review follow-up
Rejected: Revert the layout-preserving chunking refactor | would discard the verified file-layout fix and its regressions
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep chunk reassembly lossless; new chunking changes should continue to round-trip original separators byte-for-byte
Tested: npm run lint; npm run typecheck; npm test; npm run build; python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json; python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json; python3 scripts/korean_spell_check.py --file <tempfile-with-triple-blank-lines> --format json; python3 scripts/korean_spell_check.py --text 테스트 --max-chars 0 --format json (expected argument error)
Not-tested: Live multi-page service responses beyond the local smoke cases

* Preserve spell-check chunk separators when units overflow

The file-layout follow-up already preserved blank runs and indentation,
but the overlong-unit path still checked the wrong variable when
extracting a trailing separator. That could strand separators in
a standalone chunk and break exact reassembly for tight max-char
limits.

This commit fixes the separator extraction guard and locks the
behavior with a regression that proves chunk concatenation still
matches the original text when an overlong paragraph is followed
by a blank-line separator.

Constraint: File-mode reconstruction must preserve exact layout while chunking long input
Rejected: Broader chunking rewrite | existing structure only needed the overflow guard corrected
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep split_text_into_chunks chunk concatenation identical to the original input for layout-sensitive file checks
Tested: npm run lint; npm run typecheck; npm test; npm run build; python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json; python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json; python3 scripts/korean_spell_check.py --text 테스트 --max-chars 0 --format json; manual --file smoke with triple blank lines and indentation
Not-tested: Live-service failure modes such as Cloudflare/browser challenges

* Prevent clean spell-check chunks from crashing mixed file runs

The Nara/PNU surface can return a plain 'no issues found' HTML page
without the embedded result payload when a chunk is already clean.
Chunked file and markdown runs could hit that response on earlier
chunks, raise a ValueError, and never reach later chunks that still
needed corrections. Treat the no-issues page as an empty result set
and lock the behavior with narrow regression coverage.

Constraint: Upstream no-issue responses omit the JavaScript payload entirely and can split the status message across HTML whitespace
Rejected: Fabricate a synthetic result page | empty-page handling already preserves the original clean chunk
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep empty-result detection aligned with the upstream no-issues message unless the service publishes a structured empty payload contract
Tested: python3 -m unittest scripts.test_korean_spell_check
Tested: npm run lint
Tested: npm run typecheck
Tested: npm test
Tested: npm run build
Tested: python3 scripts/korean_spell_check.py --text '아버지가방에들어가신다.' --format json
Tested: python3 scripts/korean_spell_check.py --text $'아버지가방에들어가신다.\n\n아버지가방에들어가신다.' --max-chars 15 --format json
Tested: python3 scripts/korean_spell_check.py --text 테스트 --max-chars 0 --format json
Tested: python3 scripts/korean_spell_check.py --file <tmpfile> --format json
Tested: python3 scripts/korean_spell_check.py --file <tmpfile> --max-chars 18 --format json
Not-tested: Other upstream empty-result templates beyond the current no-issues HTML wording

* Make Joseon Sillok lookups reproducible from the official site

Add a joseon-sillok-search skill and a Python helper that scrape the
official Joseon Annals search/detail pages. The helper normalizes
king/year metadata, fetches detail excerpts, and locks the repository
docs plus regression coverage around the shipped workflow.

Constraint: v1 must stay on official public HTML surfaces only
Constraint: Must avoid adding new dependencies for a simple scraping helper
Constraint: Shell connectivity to sillok.history.go.kr became intermittent during final live reruns
Rejected: Ship a new npm workspace | repo skill/docs pattern is enough for v1
Rejected: Add BeautifulSoup or another parser dependency | unnecessary for the bounded HTML patterns
Confidence: medium
Scope-risk: narrow
Reversibility: clean
Directive: Keep year filtering Gregorian and derived from official regnal metadata unless the upstream site exposes a better structured contract
Tested: npm run ci
Tested: Earlier live POST/detail probes against search/searchResultList.do and /id/kda_12512030_002 during implementation
Tested: Live official article inspection for kda_12512030_002 via the public site
Not-tested: Final end-to-end CLI live run after the last refactor, because the shell hit transient TCP timeouts to sillok.history.go.kr
Related: #59

* Prevent sillok follow-up fixes from missing filtered results or weakening trust

This follow-up addresses the blocking PR review items on the Joseon Sillok helper. The search loop now derives total pages from the first live page size so king/year filtering can reach later pages, TLS verification stays enabled on both requests and urllib paths, detail parsing accepts the live classification brackets, and repo docs stop linking to missing Korean spell-check assets on this branch.

Constraint: Must preserve the existing joseon-sillok-search surface and avoid new dependencies
Rejected: Keep verify=False behind an implicit fallback | still weakens authenticity for the default path
Rejected: Infer pagination from the hardcoded 50-row default | misses valid later-page matches when the site serves smaller pages
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If the official site changes page size or pagination markup again, update the first-page pagination regression before altering search_sillok pagination math
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_sillok_search
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: python3 scripts/sillok_search.py --query '훈민정음' --king 세종 --year 1443 --limit 1 --timeout 20
Tested: python3 - <<'PY' ... fetch_detail_page(..., article_id='kda_12512030_002', timeout=20) ... PY
Not-tested: Successful live sillok.history.go.kr responses in this environment (POST and detail GET both timed out at 20s)
Related: PR #62

* Make joseon-sillok-search installs work outside the repo

The skill installer only ships the skill directory, so the sillok helper has to live inside that payload. This moves the authoritative helper into joseon-sillok-search/scripts/, keeps the repo-root script as a thin shim for tests and docs, and trims footer metadata from parsed article bodies so excerpts match the published examples.

Constraint: skills add exposes only the installed skill payload, not repo-root helpers

Rejected: Keep the helper only under scripts/ | installed skill commands fail after copy-only installs

Confidence: high

Scope-risk: narrow

Directive: Keep the repo-root sillok wrapper thin and update the bundled helper first when behavior changes

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_sillok_search

Tested: node --test scripts/skill-docs.test.js

Tested: temp installed-skill smoke run of python3 scripts/sillok_search.py --help plus footer-cleaning parse_detail_page check

Tested: npm run ci

Not-tested: live sillok.history.go.kr shell requests still hit connect timeouts in this environment

* Restore the sillok CLI's verified stdlib transport fallback

The helper already shipped a stdlib urllib opener that can reach the
official search endpoint in environments where requests/urllib3 aborts.
This change keeps that opener available even when requests imports
successfully and falls back to it on retryable requests transport
failures. Added regression coverage for opener availability and the
requests-to-urllib fallback so the default CLI path matches the live
verified behavior.

Constraint: Official sillok detail GETs can still time out transiently in this environment
Constraint: Keep TLS verification enabled and preserve the documented CLI entrypoints
Rejected: Force urllib for every request | keep the existing requests fast path when it succeeds
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve the stdlib fallback tests whenever the transport layer changes
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_sillok_search
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: live forced-fallback probe against searchResultList.do with requests.post patched to OSError(22)
Not-tested: full live CLI completion through the detail GET in this environment

* Document the approved real-estate MCP skill without vendoring upstream

Issue #53 is intentionally doc-only, so this change adds the
real-estate-search skill, feature guide, setup/security notes,
and regression coverage around the upstream real-estate-mcp
integration instead of importing server code.

The new docs keep the original MCP link, cover Codex/Claude
registration, and spell out the self-host + Cloudflare Tunnel +
launchd path for environments where no fixed hosted endpoint is
available.

Constraint: Must use tae0y/real-estate-mcp without copying its source into this repository
Constraint: Must include the original MCP link and a stable self-host fallback when no hosted endpoint is available
Rejected: Vendor the upstream MCP source | issue explicitly requires skill docs only
Rejected: Assume a public hosted MCP endpoint exists | upstream docs did not publish one
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this integration doc-only; do not add a workspace or vendored server without revisiting issue #53 constraints
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: upstream bootstrap smoke (`uv sync`, `uv run real-estate-mcp --help`, HTTP initialize on 127.0.0.1:8017)
Not-tested: live property data queries with a valid DATA_GO_KR_API_KEY

* Prevent misleading real-estate self-host instructions

Tighten the real-estate skill docs so the launchd fallback stays operational
and the Onbid bid-result tools are described with the same WIP caveat the
upstream project still publishes.

Constraint: Upstream Docker compose already uses restart: unless-stopped while `docker compose ... up -d` daemonizes immediately
Rejected: Keep a separate server LaunchAgent with RunAtLoad + KeepAlive | launchd would restart-loop on the exiting compose command
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Mirror upstream capability caveats in k-skill docs and do not wrap daemonized server commands in launchd KeepAlive jobs
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: uv sync
Tested: uv run real-estate-mcp --help
Tested: DATA_GO_KR_API_KEY=dummy uv run real-estate-mcp --transport http --host 127.0.0.1 --port 8017
Tested: curl initialize on http://127.0.0.1:8017/mcp returned protocolVersion 2024-11-05
Not-tested: Live 거래 조회 with a real DATA_GO_KR_API_KEY

* Keep install docs from reintroducing broken launchd guidance

The install guide had drifted from the real-estate skill and feature docs and still implied that macOS launchd should auto-run both the server and tunnel. This narrows the guidance back to tunnel-only launchd ownership and extends regression coverage so docs/install.md cannot silently reintroduce the server-side loop wording.

Constraint: Upstream docker compose already uses restart: unless-stopped
Constraint: Review-round-2 scope is limited to docs and regression coverage
Rejected: Leave docs/install.md under a generic launchd presence check | it missed the exact server/터널 regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep docs/install.md aligned with the detailed real-estate feature guide when self-host guidance changes
Tested: node --test scripts/skill-docs.test.js; npm run ci; fresh-clone upstream smoke with uv sync, uv run real-estate-mcp --help, and HTTP initialize on 127.0.0.1:8017/mcp
Not-tested: None

* Allow Han River water-level lookups without user API keys

The proxy now resolves HRFCO water-level stations via waterlevel/info and
fetches the latest 10-minute measurement via waterlevel/list, exposing a
public summary route plus a hosted skill/docs path.

Constraint: HRFCO requires a ServiceKey and station-code-based latest lookup
Rejected: Raw passthrough only | forces users to manage upstream details and misses the approved no-key UX
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep this route summary-only and public; expand rainfall/dam/bo/fldfct in separate issues
Tested: npm run ci; local server smoke test with HRFCO sample key against /v1/han-river/water-level; tsc diagnostics on packages/k-skill-proxy/src/server.js and src/hrfco.js
Not-tested: Hosted production proxy rollout

* Expose official nearby fuel prices for location-based gas station lookups

Add the first cheap-gas-nearby skill/package pair so nearby gas-price
queries can resolve a user-supplied location, translate it into Opinet's
KATEC search contract, and return the cheapest nearby stations with
address and facility detail. The docs and setup surfaces now advertise
the new skill and its Opinet API key requirement.

Constraint: Nearby fuel prices must come from the official KNOC Opinet API when available
Constraint: No new external dependencies were allowed for coordinate conversion or location resolution
Rejected: Map-only gas price scraping | official Opinet Open API exists and is the preferred source
Rejected: Require lat/lng input only | poorer UX than supporting landmark/station queries through anchor resolution
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep `OPINET_API_KEY` as the only supported official-price credential unless the repo adopts an Opinet proxy later
Tested: npm run ci; node --test packages/cheap-gas-nearby/test/index.test.js; offline fixture smoke via searchCheapGasStationsByLocationQuery('서울역', ...)
Not-tested: Live Opinet API call with a real `OPINET_API_KEY` (no non-placeholder key was configured locally)
Related: #54

* Explain Blue Ribbon premium gating instead of failing opaquely

Blue Ribbon's nearby endpoint now returns PREMIUM_REQUIRED for public
requests, so the package now upgrades that response into a stable domain
error and updates user-facing docs to describe the degraded nearby state.
Regression tests cover both location-query and coordinate-query entrypoints
while keeping the existing happy path intact.

Constraint: Must not attempt to bypass Blue Ribbon premium access controls
Constraint: Package releases must use Changesets metadata
Rejected: Silently return empty results on PREMIUM_REQUIRED | would hide the upstream contract change from callers
Rejected: Keep generic 403 error and docs unchanged | leaves the main failure mode opaque and misleading
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If Blue Ribbon reopens a public nearby surface later, update the docs and replace the premium_required remap only after fresh live verification
Tested: npm test --workspace blue-ribbon-nearby; npm run lint --workspace blue-ribbon-nearby; npm run ci; live node repro now returns premium_required metadata
Not-tested: Official premium-authenticated nearby flow, because no approved premium credentials are available

* Reduce duplicate premium-required test assertions

The implementation diff was already verified, so this follow-up keeps the
new regression coverage easier to read by sharing one assertion helper
instead of repeating the same predicate twice.

Constraint: Must preserve the verified PREMIUM_REQUIRED regression coverage exactly
Rejected: Leave duplicated inline predicates in both tests | repeats the same contract and adds noise to future edits
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep location-query and coordinate-query regressions aligned when the premium_required error contract changes
Tested: npm test --workspace blue-ribbon-nearby; npm run lint --workspace blue-ribbon-nearby; npm run ci
Not-tested: Additional live Blue Ribbon requests beyond the previously verified premium_required repro

* Document an Olive Young search skill around the upstream daiso CLI

Issue #61 asked for an Olive Young lookup workflow without vendoring the upstream daiso-mcp server into k-skill, so this change adds a docs-only skill, threads it through repository docs, and locks the guidance with regression assertions.

The new guidance prefers CLI-first verification (`npx daiso`) and a clone fallback (`git clone https://github.com/hmmhmmhm/daiso-mcp.git`) instead of requiring direct Claude Code MCP installation. The existing daiso-product-search skill remains untouched.

Constraint: Must mention the original https://github.com/hmmhmmhm/daiso-mcp repo
Constraint: Must avoid changing the existing daiso-product-search skill
Constraint: Must prefer upstream CLI/package usage over direct Claude Code MCP installation
Rejected: Vendor upstream Olive Young code into k-skill | contradicts the minimal-addition design request
Rejected: Make Claude Code MCP setup the default path | issue explicitly asked for CLI-first usage
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the Olive Young docs aligned with upstream `daiso` CLI behavior and note public endpoint instability when live verification shows it
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: 2026-04-05 live /tmp/daiso-mcp CLI checks for health, /api/oliveyoung/stores, /api/oliveyoung/products, /api/oliveyoung/inventory
Not-tested: Authenticated/private Olive Young flows or ordering/payment paths

* Keep Han River docs honest until hosted rollout lands

The local HRFCO proxy route already works, but the deployed public proxy still
lacks /v1/han-river/water-level as of 2026-04-05. This follow-up changes the
user-facing docs to require a self-hosted or deployment-verified proxy URL,
keeps the intended hosted path documented as rollout-pending, and locks that
caveat with a regression test.

Constraint: Hosted k-skill-proxy deployment still returns 404 for /v1/han-river/water-level on 2026-04-05
Rejected: Keep advertising the hosted route as the default live path | current deployment is not live yet
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Restore hosted-default wording only after the public proxy route and HRFCO configuration are verified live
Tested: node --test scripts/skill-docs.test.js; npm run ci; local mocked smoke via node packages/k-skill-proxy/src/server.js + GET /v1/han-river/water-level?stationName=한강대교
Not-tested: Live hosted k-skill-proxy behavior after deployment

* Keep cheap gas lookups from failing on recoverable Kakao and input issues

The lookup now walks ranked Kakao anchor candidates until one returns usable coordinates, and it rejects invalid limit inputs instead of silently collapsing non-empty results into an empty list. The regression suite now locks both review repros plus the null-coordinate normalization edge case that surfaced while implementing the fallback.

Constraint: Kakao place panels can be partially populated or return 404 for otherwise valid search candidates
Rejected: Default invalid limit/detailLimit values silently | still masks caller bugs and can look like no results nearby
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep anchor resolution tolerant of unusable Kakao panels before failing the whole lookup
Tested: node --test packages/cheap-gas-nearby/test/index.test.js; npm run ci; offline fixture smoke for searchCheapGasStationsByLocationQuery('서울역', ...)
Not-tested: Live Opinet/Kakao network path without a real OPINET_API_KEY

* Keep Olive Young install guidance aligned with live retry behavior

Issue #61's initial branch already shipped the olive-young-search skill/docs set, but the install guide did not have a regression lock for the public endpoint instability note captured during live verification. This follow-up adds that test first and updates the install quickstart so retry-or-clone fallback guidance stays in sync with the verified daiso CLI workflow.

Constraint: Must keep the follow-up scoped to the approved issue #61 docs behavior
Constraint: Public olive-young endpoint can intermittently return 5xx/503 during live verification
Rejected: Re-open the broader feature docs set | the existing branch already covered the main feature scope
Rejected: Leave the retry guidance untested in install docs | risk of future doc drift across surfaces
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If live olive-young verification changes again, update install docs and the regression together so retry/fallback guidance stays honest
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: cd /tmp/daiso-mcp && npx daiso health
Tested: cd /tmp/daiso-mcp && npx daiso get /api/oliveyoung/stores --keyword 명동 --limit 3 --json
Tested: cd /tmp/daiso-mcp && npx daiso get /api/oliveyoung/products --keyword 선크림 --size 3 --json
Tested: cd /tmp/daiso-mcp && npx daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 2 --json
Not-tested: Authenticated Olive Young flows or sustained-rate retry behavior beyond the documented smoke checks

* Preserve ranked Kakao anchor fallbacks after panel failures

The resolver already retried later Kakao panels, but after the best panel failed it
walked the remaining candidates in raw HTML order. Centralizing the full
anchor ranking in parse.js keeps fallback iteration aligned with the
existing scoring rules and locks the review repro with a regression test
for 강남역 ordering.

Constraint: Kakao place panels can 404 or omit coordinates even when later ranked candidates are usable
Rejected: Re-rank only after a failed panel | duplicates scoring logic and drifts from selectAnchorCandidate
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep resolveAnchor fallback order tied to the shared anchor scoring logic rather than raw Kakao result order
Tested: node --test packages/cheap-gas-nearby/test/index.test.js; npm run ci; offline fixture smoke for searchCheapGasStationsByLocationQuery('서울역', ...); lsp diagnostics on affected files
Not-tested: Live Kakao/Opinet network calls with a non-placeholder OPINET_API_KEY

* Clarify that Blue Ribbon coordinate lookups fail the same way

The premium gate now affects both location-query and coordinate-entry nearby flows. Record that parity in the skill and docs so the live 2026-04-05 repro evidence stays aligned with the shipped public contract.

Constraint: /restaurants/map remains premium-gated for public requests as of 2026-04-05
Rejected: Add more code changes without a behavior gap | would add churn after the approved fix already landed
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the docs/examples aligned with live repro evidence whenever Blue Ribbon changes nearby access behavior
Tested: npm test --workspace blue-ribbon-nearby; npm run lint --workspace blue-ribbon-nearby; npm run ci; live node repro for location + coordinates premium_required contract
Not-tested: New upstream success-path live nearby payloads, because public access is still premium-gated

* Document a runnable Olive Young clone fallback

The follow-up review found that clone-local `npx daiso` commands do not run from a built `hmmhmmhm/daiso-mcp` checkout because the generated bin file is not executable. This updates the olive-young skill and docs to use the verified `node dist/bin.js ...` path for clone fallback, and locks that behavior with regression tests while keeping the public CLI-first path unchanged.\n\nConstraint: Upstream clone checkouts can fail with `Permission denied` when invoked through clone-local `npx daiso`\nConstraint: Keep the existing daiso-product-search skill untouched\nRejected: Leave install docs unchanged | PR body and docs would keep a broken clone fallback path\nRejected: Vendor or patch upstream daiso-mcp inside k-skill | issue explicitly requires documenting upstream flow instead\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep clone-fallback docs and PR verification commands aligned with the actually runnable local invocation\nTested: node --test scripts/skill-docs.test.js\nTested: npm run ci\nTested: cd /tmp/daiso-mcp && npm install && npm run build && node dist/bin.js health\nTested: cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 3 --json\nTested: cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 3 --json\nTested: cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 2 --json\nNot-tested: Public npx endpoint stability beyond the verified smoke-test window

* Record fresh verification for the approved cheap-gas follow-up

The ranked Kakao fallback-order and invalid-limit fixes are already present on feature/#54, so no further code edits were necessary. This empty follow-up commit records the requested rerun verification for PR #67 before posting the implementation update.\n\nConstraint: Existing branch head already contains the approved cheap-gas-nearby follow-up\nRejected: Invent another code/doc change just to force a non-empty diff | unnecessary risk after approval\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Kakao anchor fallback iteration tied to the shared ranking helper and preserve finite-number validation for limit inputs\nTested: node --test packages/cheap-gas-nearby/test/index.test.js; npm run ci; offline fixture smoke for searchCheapGasStationsByLocationQuery('서울역', ...); lsp diagnostics on affected files\nNot-tested: Live Kakao/Opinet network calls with a non-placeholder OPINET_API_KEY

* Record verified delivery for approved cheap-gas-nearby rollout

The approved Issue #54 implementation was already present on feature/#54 when this follow-up began, so this checkpoint records fresh verification evidence and keeps PR #67 moving without introducing extra code churn.

Constraint: Existing approved fixes were already on the branch and the follow-up still required a pushed update plus implementation comment
Rejected: Add additional code or docs churn | approved scope was already satisfied and green locally
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Preserve the Kakao fallback-order and invalid-count regression coverage unless equivalent runtime repros replace it
Tested: node --test packages/cheap-gas-nearby/test/index.test.js; offline fixture smoke for searchCheapGasStationsByLocationQuery('서울역', ...); npm run ci; LSP diagnostics on affected cheap-gas-nearby files
Not-tested: Live Kakao/Opinet network path without a non-placeholder OPINET_API_KEY

* Keep Blue Ribbon premium-gate remapping explicitly bounded

The issue #63 fix already remaps PREMIUM_REQUIRED for nearby lookups. This follow-up adds a regression that proves non-premium /restaurants/map failures still surface as generic request errors, and clarifies that boundary in the package and feature docs.

Constraint: PR #68 already carries the shipped behavior change and must stay aligned with current live premium-gated upstream behavior
Rejected: Broaden domain remapping to all 403 nearby errors | would hide distinct upstream failure modes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep only PREMIUM_REQUIRED on /restaurants/map mapped to premium_required unless a new verified upstream contract is documented
Tested: npm test --workspace blue-ribbon-nearby; npm run lint --workspace blue-ribbon-nearby; npm run ci; live node repro for location+coordinate nearby lookups on 2026-04-06; LSP diagnostics on src/test
Not-tested: Alternative non-premium upstream status codes beyond the mocked 403 ACCESS_DENIED path

* Keep Olive Young clone fallback docs runnable from a fresh clone

Round 3 review found that the inline fallback shorthand skipped `cd daiso-mcp`,
which made `npm install` run outside the cloned upstream repo even though the
fenced examples were already correct. This tightens the two published shorthand
snippets and adds regression coverage so both docs surfaces reject the broken
chain in future edits.

Constraint: Existing node dist/bin.js clone fallback examples were already verified and had to stay aligned
Constraint: The follow-up had to stay scoped to docs/test coverage without touching the existing daiso-product-search skill
Rejected: Convert the shorthand to prose only | the review specifically requested a runnable inline form or an explicit removal
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep inline clone fallback snippets synchronized with the fenced node dist/bin.js examples and require `cd daiso-mcp` before install/build
Tested: node --test scripts/skill-docs.test.js; npm run ci; cd /tmp/daiso-mcp && npm install && npm run build; cd /tmp/daiso-mcp && node dist/bin.js health; cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 3 --json; cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 3 --json; cd /tmp/daiso-mcp && node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 2 --json
Not-tested: Public npx path in this follow-up round (previous PR verification already covered it)
Related: PR #71

* Bundle korean-spell-check script inside skill directory for packageless installs

The Python helper lived only in the repo-root scripts/ folder, so
`skills add` never shipped it. Move the real implementation into
korean-spell-check/scripts/ (mirroring joseon-sillok-search) and
replace the root copy with a thin re-export wrapper so lint/test
still resolve from the repo root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Exclude .claude directory from skill validation to fix CI in worktrees

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* real-estate-search: MCP self-host → k-skill-proxy HTTP 전환

사용자가 직접 real-estate-mcp를 clone/self-host하는 대신
k-skill-proxy에 MOLIT 실거래가 API route를 추가해서
다른 스킬(한강수위, 미세먼지 등)과 동일한 패턴으로 사용 가능하게 함.

- GET /v1/real-estate/region-code — 지역코드 검색
- GET /v1/real-estate/:assetType/:dealType — 9개 거래 유형 조회
- molit.js: XML 파싱, 필드 정규화, 취소거래 필터링, 요약통계
- region-lookup.js: 284개 법정동 5자리 코드 토큰 매칭
- SKILL.md/docs를 HTTP proxy 기반으로 전면 재작성
- DATA_GO_KR_API_KEY를 사용자 필수항목에서 프록시 운영자 전용으로 이동

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* cheap-gas-nearby: proxy 경유로 전환 + 프로덕션 배포 구조 문서화

- cheap-gas-nearby: OPINET_API_KEY 없이 k-skill-proxy 경유로 동작하도록 변경
  - fetchAroundStations/fetchDetailById에 proxy fallback 추가
  - SKILL.md, feature doc에서 사용자 API key 요구 제거
- AGENTS.md, CLAUDE.md: proxy 개발/배포 워크플로우 문서화
- docs/features/k-skill-proxy.md: opinet route 추가, 프로덕션 자동 배포 구조 설명

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* blue-ribbon-nearby: proxy 경유로 전환 + 프리미엄 세션 프록시 라우트

Blue Ribbon /restaurants/map이 프리미엄 전용으로 전환되어,
k-skill-proxy에 BLUE_RIBBON_SESSION_ID 기반 프록시 라우트를 추가하고
blue-ribbon-nearby 패키지가 기본적으로 프록시를 경유하도록 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-06 17:44:23 +09:00 committed by GitHub
commit 9f29ed5714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 11249 additions and 66 deletions

View file

@ -0,0 +1,5 @@
---
"lck-analytics": minor
---
Add the first LCK analytics package and skill pack adapted from jerjangmin's original upstream implementation.

View file

@ -0,0 +1,5 @@
---
"cheap-gas-nearby": minor
---
Publish the first official Opinet-powered nearby cheapest gas station lookup package and skill docs.

View file

@ -0,0 +1,5 @@
---
"blue-ribbon-nearby": patch
---
Handle Blue Ribbon `PREMIUM_REQUIRED` nearby responses with a domain error and document the current premium gate on live nearby results.

View file

@ -0,0 +1,5 @@
---
"used-car-price-search": minor
---
Publish the first reusable used-car-price-search package with the SK direct inventory parser and skill docs.

View file

@ -0,0 +1,26 @@
# PRD: Issue 56 - Han River water-level proxy endpoint
## Goal
한강홍수통제소(HRFCO) Open API의 `waterlevel/info` + `waterlevel/list/10M``k-skill-proxy` 의 공개 read-only endpoint로 감싸서, 최종 사용자가 별도 ServiceKey 없이 한강 수위·유량을 조회할 수 있게 한다.
## User story
- 사용자는 "한강대교 지금 수위 어때?"처럼 관측소명 또는 관측소코드로 현재 수위와 유량을 빠르게 확인하고 싶다.
- 에이전트는 proxy가 주는 현재 관측값과 기준 수위를 요약해 답변한다.
## Scope
- `k-skill-proxy` 에 HRFCO waterlevel summary endpoint 추가
- proxy 환경변수/README/가이드 문서 반영
- 신규 han-river-water-level skill 및 기능 문서 추가
- 문서 회귀 테스트 + proxy server tests 추가
## Non-goals
- `rainfall` / `dam` / `bo` / `fldfct` 전체 확장
- private/auth-required proxy 도입
- 지도 기반 위치 추천 또는 관측소 선택 UX 고도화
## Acceptance criteria
1. proxy server 가 공개 read-only endpoint 로 HRFCO 현재 수위/유량을 요약 제공한다.
2. upstream HRFCO ServiceKey 는 proxy 서버 환경변수로만 관리된다.
3. endpoint 는 관측소명/관측소코드 기준 최신 관측시각, 수위, 유량, 기준수위를 포함한 JSON 을 반환한다.
4. 신규 skill/docs 는 hosted proxy 기본 경로와 무-key client workflow 를 문서화한다.
5. 로컬 테스트 및 최소 1회 실제 서버 실행/요청 검증을 완료한다.

View file

@ -0,0 +1,11 @@
# Test Spec: Issue 56 - Han River water-level proxy endpoint
## Regression coverage
1. `packages/k-skill-proxy/test/server.test.js` 에 HRFCO endpoint allowlist/serviceKey injection/public access/cache/ambiguous station assertions 추가
2. `scripts/skill-docs.test.js` 에 신규 han-river-water-level skill/docs/README/setup/sources/roadmap 노출면 검증 추가
3. root `npm test` 와 proxy workspace tests 가 모두 통과
## Manual verification
1. `node packages/k-skill-proxy/src/server.js` 로 로컬 서버 기동
2. health 와 새 HRFCO endpoint 를 실제 HTTP 요청으로 확인
3. station name / station code / 잘못된 입력에 대한 보수적 응답 확인

View file

@ -34,3 +34,14 @@ These rules are repo-specific and apply to everything under this directory.
- Default posture: public read-only endpoint, **no proxy auth by default**.
- Keep free-API proxy surfaces narrow, allowlisted, cache-backed, and rate-limited.
- If abuse or operational issues appear later, add stricter controls then instead of preemptively requiring auth.
## Proxy server development
- 개발 repo (`dev` 브랜치)에서 proxy 코드를 수정하고, main에 merge하면 프로덕션에 반영된다.
- 프로덕션 배포본은 `~/.local/share/k-skill-proxy`에 main 브랜치 단독 clone으로 존재한다.
- cron job (`0 * * * *`)이 매시 정각에 `~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh`를 실행해 origin/main fetch → fast-forward pull → package-lock 변경 시 npm ci → pm2 restart 순서로 자동 배포한다.
- 로그: `/tmp/k-skill-proxy-update.log`
- proxy 서버 코드: `packages/k-skill-proxy/src/server.js`
- proxy 서버 테스트: `packages/k-skill-proxy/test/server.test.js`
- proxy 환경변수(API key 등)는 `~/.config/k-skill/secrets.env`에 넣고, `scripts/run-k-skill-proxy.sh`가 source한다.
- **dev에서 route를 추가/수정한 뒤 main에 merge되기 전까지는 프로덕션 proxy에 반영되지 않는다.** 로컬 테스트는 `node packages/k-skill-proxy/src/server.js`로 직접 실행한다.

View file

@ -3,3 +3,11 @@
## Testing anti-patterns
- **Never write tests that assert `.changeset/*.md` files exist.** Changesets are consumed (deleted) by `changeset version` during the release flow. Any test guarding changeset file presence will break CI on the version-bump commit and block the release pipeline.
## Proxy server development
- 개발 repo: `/Users/jeffrey/Projects/k-skill` (이 디렉토리, `dev` 브랜치)
- 프로덕션 배포본: `~/.local/share/k-skill-proxy` (main 브랜치 단독 clone)
- **cron job** 이 매시 정각에 `origin/main` fetch → fast-forward pull → pm2 restart 실행
- 따라서 proxy route 변경은 **main에 merge되어야 프로덕션에 반영**된다. dev에서 코드를 바꿔도 프로덕션 proxy에는 영향 없음.
- 로컬 테스트는 `node packages/k-skill-proxy/src/server.js` 로 직접 실행하거나 `node --test packages/k-skill-proxy/test/server.test.js` 로 확인.

View file

@ -23,19 +23,26 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 프록시 URL 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | self-host 또는 배포 확인이 끝난 `k-skill-proxy` 경유로 관측소 기준 현재 수위·유량·기준수위 확인 | 프록시 URL 필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | 날짜별 LCK 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 블루리본 서베이 공식 표면으로 근처 블루리본 맛집 검색 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 k-skill-proxy 경유로 블루리본 nearby 맛집 검색 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| 근처 술집 조회 | 현재 위치(서울역/강남/사당 등)를 먼저 확인한 뒤 카카오맵 기준으로 영업 상태·메뉴·좌석·전화번호가 포함된 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 공식 우체국 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 올리브영 검색 | upstream [`daiso`](https://www.npmjs.com/package/daiso) CLI / [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 기반으로 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | [coupang-mcp](https://github.com/uju777/coupang-mcp) 서버 경유로 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 중고차 가격 조회 | 주요 렌터카 업체 비교 후 SK렌터카 다이렉트 타고BUY inventory snapshot 기준으로 인수가/월 렌트료 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
| 한국어 맞춤법 검사 | 바른한글(구 부산대 맞춤법/문법 검사기) 공개 표면을 저빈도 청크 검사로 호출해 교정안과 이유를 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
## 처음 시작하는 순서
@ -65,9 +72,14 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)
@ -75,9 +87,11 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [릴리스/배포 가이드](docs/releasing.md)
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.

View file

@ -1,6 +1,6 @@
---
name: blue-ribbon-nearby
description: Use when the user asks for nearby restaurants or 근처 맛집. Always ask the user's current location first, then search official 블루리본 Blue Ribbon Survey ribbon restaurants near that location.
description: Use when the user asks for nearby restaurants or 근처 맛집 and wants 블루리본 picks. Always ask the user's current location first, then search official Blue Ribbon nearby restaurants via k-skill-proxy.
license: MIT
metadata:
category: food
@ -12,12 +12,13 @@ metadata:
## What this skill does
유저가 알려준 현재 위치를 기준으로 블루리본 서베이 공식 검색 표면에서 **근처 블루리본 맛집**만 추려서 보여준다.
유저가 알려준 현재 위치를 기준으로 블루리본 서베이 공식 zone 을 찾고, k-skill-proxy 를 경유해 **근처 블루리본 맛집**을 보여준다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 위치 문자열은 공식 `zone` 목록으로 매칭하고, 가능하면 주변 JSON endpoint 로 좁혀서 찾는다.
- 위치 문자열은 공식 `zone` 목록으로 매칭하고, 주변 JSON endpoint 로 좁혀서 찾는다.
- 좌표를 직접 받으면 더 정확한 nearby 검색을 할 수 있다.
- nearby 검색은 기본적으로 k-skill-proxy (`/v1/blue-ribbon/nearby`) 를 경유한다. 프록시에 `BLUE_RIBBON_SESSION_ID` 가 설정되어 있어야 한다.
## When to use
@ -87,7 +88,7 @@ metadata:
### 3. Query the nearby Blue Ribbon endpoint
공식 JSON endpoint 에 nearby 조건을 붙여 호출한다.
기본적으로 k-skill-proxy 를 경유해 nearby 결과를 가져온다.
```js
const { searchNearbyByLocationQuery } = require("blue-ribbon-nearby");
@ -101,7 +102,9 @@ console.log(result.anchor);
console.log(result.items);
```
내부적으로는 `ribbon=true`, `ribbonType=RIBBON_THREE,RIBBON_TWO,RIBBON_ONE`, `isAround=true`, `sort=distance`, `zone2Lat`, `zone2Lng` 같은 파라미터를 사용한다.
내부적으로는 zone 매칭 후 프록시의 `/v1/blue-ribbon/nearby` 에 좌표와 거리를 넘긴다. 프록시가 프리미엄 세션으로 Blue Ribbon upstream 을 호출한다.
직접 호출이 필요하면 `useDirectApi: true` 옵션을 쓸 수 있지만, 프리미엄 세션 없이는 `premium_required` 에러가 난다.
### 4. Respond with a short restaurant summary
@ -116,7 +119,7 @@ console.log(result.items);
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 공식 Blue Ribbon nearby 결과를 최소 1개 이상 찾았거나, 찾지 못한 이유와 다음 질문을 제시했다.
- 공식 Blue Ribbon nearby 결과를 최소 1개 이상 찾았거나, 프록시 미설정 등의 이유로 결과를 가져올 수 없다는 이유와 다음 질문을 제시했다.
- 결과를 거리순으로 짧게 정리했다.
## Failure modes
@ -124,6 +127,7 @@ console.log(result.items);
- 위치 문자열이 공식 zone 과 잘 매칭되지 않을 수 있다.
- 같은 키워드가 여러 상권에 걸치면 추가 확인이 필요하다.
- Blue Ribbon 사이트가 구조/파라미터를 바꾸면 zone 파싱 또는 nearby endpoint 가 깨질 수 있다.
- 프록시의 `BLUE_RIBBON_SESSION_ID` 가 만료(30일)되면 갱신이 필요하다.
## Notes

117
cheap-gas-nearby/SKILL.md Normal file
View file

@ -0,0 +1,117 @@
---
name: cheap-gas-nearby
description: Use when the user asks for nearby cheapest gas stations or 근처 가장 싼 주유소. Always ask the user's current location first, then use Kakao Map anchor resolution plus official Opinet fuel-price APIs.
license: MIT
metadata:
category: transport
locale: ko-KR
phase: v1
---
# Cheap Gas Nearby
## What this skill does
유저가 알려준 현재 위치를 기준으로 **근처에서 가장 싼 주유소**를 찾아준다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 가격 데이터는 한국석유공사 **Opinet 공식 API**를 우선 사용한다.
- 동네/역명/랜드마크 입력은 Kakao Map anchor 검색으로 좌표를 잡은 뒤 Opinet nearby 검색으로 연결한다.
- 기본 제품은 **휘발유(B027)** 이고, 유저가 경유라고 명시하면 **경유(D047)** 로 바꾼다.
## When to use
- "근처 가장 싼 주유소 찾아줘"
- "서울역 근처 휘발유 제일 싼 데 어디야?"
- "강남에서 경유 싼 주유소 몇 군데만 보여줘"
- "지금 여기 근처 셀프주유소 중 싼 순으로 알려줘"
## Mandatory first question
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
- 권장 질문: `현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처에서 가장 싼 주유소를 찾아볼게요.`
- 제품이 불명확하면: `휘발유 기준으로 볼까요, 경유 기준으로 볼까요? 따로 말씀 없으면 휘발유로 찾을게요.`
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
## Default path
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/opinet/around``/v1/opinet/detail` 을 경유해 조회한다. 사용자 쪽에서 별도 `OPINET_API_KEY` 를 준비할 필요가 없다.
## Official Opinet surfaces
- 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
반경 검색 핵심 파라미터:
- `x`, `y`: 기준 위치 **KATEC** 좌표
- `radius`: 반경(m, 최대 5000)
- `prodcd`: `B027`(휘발유), `D047`(경유), `B034`(고급휘발유), `C004`(등유), `K015`(LPG)
- `sort=1`: 가격순
## Location resolution surface
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
위치 문자열은 Kakao Map으로 **anchor 좌표(WGS84)** 를 구한 뒤, 내부적으로 **WGS84 → KATEC** 변환을 적용해 Opinet `aroundAll.do` 에 넘긴다.
## Workflow
1. 유저에게 반드시 현재 위치를 묻는다.
2. 위치 문자열을 받으면 Kakao Map anchor 검색으로 좌표를 찾는다.
- 위도/경도를 직접 받으면 anchor 검색을 생략한다.
3. 좌표를 KATEC으로 변환한다.
4. Opinet `aroundAll.do``sort=1` 가격순으로 조회한다.
5. 상위 후보에 대해 `detailById.do` 를 호출해 도로명주소, 전화번호, 셀프 여부, 세차장, 경정비, 품질인증 여부를 보강한다.
6. 보통 3~5개만 짧게 정리한다.
## Responding
결과는 보통 아래 필드를 포함해 짧게 정리한다.
- 주유소명
- 가격(휘발유/경유 중 요청한 제품)
- 거리
- 주소
- 셀프 여부
- 세차장/경정비/품질인증 여부(있으면)
## Node.js example
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
productCode: "B027",
radius: 1000,
limit: 3
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 기본 proxy 경유로 Opinet 데이터를 조회했다.
- 공식 Opinet nearby 결과를 최소 1개 이상 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
- 가격순 상위 결과를 3~5개 이내로 정리했다.
## Failure modes
- 프록시 서버가 내려가 있거나 `OPINET_API_KEY` 가 서버에 설정되지 않은 경우.
- Kakao Map anchor가 애매하면 좌표가 잘못 잡힐 수 있어 추가 위치 확인이 필요하다.
- Opinet Open API 응답이 일시적으로 비거나 갱신 중일 수 있다.

View file

@ -2,11 +2,14 @@
## 이 기능으로 할 수 있는 일
- 유저가 알려준 현재 위치 근처의 블루리본 맛집 검색
- 유저가 알려준 현재 위치를 공식 Blue Ribbon zone 으로 매칭
- 공식 Blue Ribbon zone 목록으로 동네/역명 매칭
- 좌표 기반 nearby 검색
- k-skill-proxy 경유 nearby 블루리본 맛집 검색
- 거리순 상위 결과 정리
> [!NOTE]
> Blue Ribbon의 `/restaurants/map` 은 프리미엄 전용입니다. 이 기능은 k-skill-proxy 에 설정된 프리미엄 세션(`BLUE_RIBBON_SESSION_ID`)을 경유해 nearby 검색을 수행합니다.
## 먼저 필요한 것
- 인터넷 연결
@ -58,7 +61,7 @@
2. 동네/역명/랜드마크를 받으면 공식 `search/zone` 목록에서 가장 가까운 zone 후보를 찾습니다.
- 공식 zone 이름이 아닌 대표 랜드마크는 먼저 nearest zone alias 로 확장합니다. 예: `코엑스``삼성동/대치동`
3. 좌표를 받으면 nearby bounding box 를 계산합니다.
4. 공식 `/restaurants/map` endpoint 를 `isAround=true`, `ribbon=true`, `ribbonType=RIBBON_THREE,RIBBON_TWO,RIBBON_ONE`, `sort=distance` 로 조회합니다.
4. k-skill-proxy 의 `/v1/blue-ribbon/nearby` 에 좌표와 거리를 넘겨 nearby 검색을 수행합니다. 프록시가 프리미엄 세션으로 `/restaurants/map` 을 호출합니다.
5. 거리순 상위 결과를 3~5개 정리합니다.
## Node.js 예시
@ -82,7 +85,9 @@ main().catch((error) => {
});
```
## 검증된 live smoke 예시
기본적으로 k-skill-proxy 를 경유합니다. 직접 호출이 필요하면 `useDirectApi: true` 옵션을 사용하세요 (프리미엄 세션 없이는 `premium_required` 에러).
## Live smoke 예시
아래 값은 **2026-03-27**`광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출해 확인한 결과 일부입니다.
@ -123,6 +128,6 @@ main().catch((error) => {
## 주의할 점
- Blue Ribbon 사이트는 browser-like 요청 헤더가 없으면 403 이 나올 수 있습니다.
- Blue Ribbon `/restaurants/map` 은 프리미엄 전용입니다. k-skill-proxy 에 `BLUE_RIBBON_SESSION_ID` 가 설정되어 있어야 합니다 (30일마다 갱신).
- 검색 페이지의 zone 목록이 바뀌면 매칭 결과도 바뀔 수 있습니다.
- 좌표 없이 너무 넓은 지역명만 받으면 상권 후보가 많아질 수 있습니다.

View file

@ -0,0 +1,106 @@
# 근처 가장 싼 주유소 찾기 가이드
## 이 기능으로 할 수 있는 일
- 현재 위치 기준 근처 최저가 주유소 검색
- 휘발유/경유 기준 nearby 가격 비교
- Opinet 공식 API(`aroundAll.do`, `detailById.do`) 기반 요약
- 셀프 여부, 세차장, 경정비, 품질인증 여부까지 함께 정리
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
- `cheap-gas-nearby` package 또는 이 저장소 전체 설치
사용자 쪽에서 별도 `OPINET_API_KEY` 를 준비할 필요가 없다. 기본적으로 `https://k-skill-proxy.nomadamas.org` 프록시를 경유하며, upstream key는 proxy 서버에서만 주입한다.
## 가장 먼저 할 일
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
권장 질문 예시:
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처에서 가장 싼 주유소를 찾아볼게요.
```
제품을 안 알려주면 보통 **휘발유(B027)** 기준으로 시작하고, 경유가 필요하면 `D047` 로 바꿉니다.
## 입력값
- 동네/상권: `강남`, `성수동`, `판교`
- 역명/랜드마크: `서울역`, `강남역`, `코엑스`
- 좌표: `37.55472, 126.97068`
- 제품코드: `B027`(휘발유), `D047`(경유)
위치 문자열은 Kakao Map anchor 검색으로 **WGS84 좌표**를 잡고, 내부적으로 **KATEC** 으로 변환해 Opinet nearby 검색에 사용합니다.
## 공식 표면
- Opinet 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
Opinet nearby 검색의 핵심 파라미터:
- `x`, `y`: 기준 위치 **KATEC** 좌표
- `radius`: 반경(m, 최대 5000)
- `prodcd`: `B027`, `D047`, `B034`, `C004`, `K015`
- `sort=1`: 가격순
## 기본 흐름
1. 유저에게 현재 위치를 먼저 묻습니다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
3. 좌표를 **WGS84 → KATEC** 으로 변환합니다.
4. Opinet `aroundAll.do``sort=1` 가격순으로 조회합니다.
5. 상위 후보는 `detailById.do` 로 재조회해 주소/전화번호/편의시설을 보강합니다.
6. 가격순 상위 3~5개만 짧게 응답합니다.
## Node.js 예시
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
productCode: "B027",
radius: 1000,
limit: 3
});
for (const item of result.items) {
console.log(`${item.name}: ${item.price}원/L, ${item.distanceMeters}m, ${item.roadAddress}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Offline smoke example
실제 키가 없는 환경에서도 패키지 동작은 fixture 기반으로 검증할 수 있습니다.
```bash
node --test packages/cheap-gas-nearby/test/index.test.js
```
## 운영 팁
- 프록시 서버가 내려가 있거나 upstream key가 없으면 503 을 반환하므로 상태를 안내합니다.
- 서울역/강남처럼 넓은 질의는 anchor 위치가 흔들릴 수 있으니 필요하면 더 구체적인 역 출구/동 이름을 한 번 더 받습니다.
- 동일 가격이면 거리순으로 다시 정렬해 보여주는 편이 좋습니다.
- 결과가 너무 많으면 반경을 `1000m` 또는 `2000m` 정도로 유지하는 편이 읽기 쉽습니다.
## 주의할 점
- Opinet Open API 인증키는 proxy 서버에서만 관리합니다. 사용자/client 쪽 secrets 파일에는 넣지 않습니다.
- Kakao Map anchor 검색은 위치 기준점만 잡기 위한 보조 단계이고, 최종 가격/순위 데이터는 Opinet을 기준으로 합니다.
- Opinet 응답의 좌표는 KATEC 이므로 WGS84와 혼동하면 안 됩니다.

View file

@ -0,0 +1,108 @@
# 한강 수위 정보 가이드
## 이 기능으로 할 수 있는 일
- 한강홍수통제소(HRFCO) 관측소명/관측소코드 기준 현재 수위 확인
- 현재 유량(`FW`) 같이 확인
- 관심/주의/경보/심각 기준수위 같이 확인
- 별도 사용자 `ServiceKey` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
## 기본 경로
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/han-river/water-level` 로 요청한다.
사용자는 별도 HRFCO `ServiceKey` 를 준비하지 않는다. upstream key는 proxy 서버에서만 `HRFCO_OPEN_API_KEY` 로 관리한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 hosted path를 사용한다.
## 입력값
- 기본: 관측소명/교량명 (`stationName`)
- 대체: 관측소코드 (`stationCode`)
예: `한강대교`, `잠수교`, `1018683`
## 기본 흐름
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/han-river/water-level` endpoint 를 호출한다.
2. proxy 는 HRFCO `waterlevel/info.json` 을 읽어 관측소명 → `WLOBSCD` 를 해석한다.
3. 해석된 `WLOBSCD``waterlevel/list/10M/{WLOBSCD}.json` 최신 10분 자료를 조회한다.
4. 관측시각, 수위(`WL`), 유량(`FW`), 기준수위 메타데이터를 요약해서 반환한다.
5. 관측소명이 여러 개에 걸리면 `ambiguous_station` + `candidate_stations` 를 반환한다.
## 예시
proxy URL 이 준비된 뒤 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
관측소코드 직접 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationCode=1018683'
```
애매한 관측소명 예시:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationName=한강'
```
예상 응답 예시:
```json
{
"station_code": "1018683",
"station_name": "한강대교",
"agency_name": "한강홍수통제소",
"address": "서울특별시 용산구 한강대교",
"observed_at": "2026-04-05T19:00:00+09:00",
"water_level": {
"value_m": 0.66,
"unit": "m"
},
"flow_rate": {
"value_cms": 208.58,
"unit": "m^3/s"
},
"thresholds": {
"interest_level_m": 5.5,
"warning_level_m": 8,
"alarm_level_m": 10,
"serious_level_m": 11,
"plan_flood_level_m": 13
},
"special_report_station": true
}
```
## fallback / 대체 흐름
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy를 우선 사용한다.
- 기본 hosted path는 `https://k-skill-proxy.nomadamas.org/v1/han-river/water-level` 이다.
- self-host 운영자는 서버 쪽에만 `HRFCO_OPEN_API_KEY` 를 넣는다.
- 사용자/client 쪽 secrets 파일에는 HRFCO key 를 넣지 않는다.
## 주의할 점
- HRFCO 레퍼런스는 이 데이터를 원시자료로 설명하므로 조회 시각을 함께 적는다.
- 기본 endpoint 는 현재값 중심이라 기간별 시계열은 직접 노출하지 않는다.
- 관측소명이 너무 넓으면 `candidate_stations` 로 좁힌 뒤 다시 조회한다.
- 최신 자료는 보통 10분 단위지만 관측소별 수집 지연이 있을 수 있다.
- 별도 proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 명시한다.
## 참고 표면
- 공식 레퍼런스: `https://www.hrfco.go.kr/web/openapiPage/reference.do`
- 인증키 안내: `https://www.hrfco.go.kr/web/openapiPage/certifyKey.do`
- 정책: `https://www.hrfco.go.kr/web/openapi/policy.do`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -0,0 +1,90 @@
# 조선왕조실록 검색 가이드
## 이 기능으로 할 수 있는 일
- 조선왕조실록 공식 사이트에서 키워드 검색
- 검색 결과를 왕별로 좁혀 보기
- 서기 연도(`1443`처럼 Gregorian year) 기준으로 결과 필터링
- 기사 상세에서 국역/원문 excerpt 가져오기
- JSON 형태로 후속 자동화에 넘기기
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- 설치된 `joseon-sillok-search` skill 안에 `scripts/sillok_search.py` helper 포함
별도 API 키나 로그인은 필요 없다.
## 공식 표면
- 메인: `https://sillok.history.go.kr`
- 검색 결과: `https://sillok.history.go.kr/search/searchResultList.do`
- 기사 상세: `https://sillok.history.go.kr/id/<article_id>`
## 기본 흐름
1. 검색어를 받는다.
2. 공식 검색 결과 HTML을 POST로 가져온다.
3. 검색 결과에서 기사 링크, 왕별 분류, 요약을 파싱한다.
4. `--king`, `--year`가 있으면 결과를 좁힌다.
5. 선택된 기사 상세 페이지를 열어 국역/원문 excerpt를 붙인다.
6. 최종 결과를 JSON으로 출력한다.
## CLI 예시
### 기본 검색
```bash
python3 scripts/sillok_search.py --query "훈민정음"
```
### 왕 + 연도 필터
```bash
python3 scripts/sillok_search.py --query "훈민정음" --king "세종" --year 1443 --limit 3
```
### 원문 검색
```bash
python3 scripts/sillok_search.py --query "훈민정음" --type w --limit 3
```
## 응답 예시 포맷
```json
{
"query": "훈민정음",
"type": "k",
"filters": {
"king": "세종",
"year": 1443,
"limit": 3
},
"total_results": 21,
"type_count": 11,
"returned_count": 1,
"items": [
{
"article_id": "kda_12512030_002",
"url": "https://sillok.history.go.kr/id/kda_12512030_002",
"title": "세종실록 102권, 세종 25년 12월 30일 경술 2번째기사 / 훈민정음을 창제하다",
"king": "세종",
"gregorian_year": 1443,
"excerpt": "이달에 임금이 친히 언문(諺文) 28자(字)를 지었는데..."
}
]
}
```
## 구현 메모
- v1 은 검색 결과 HTML과 기사 상세 HTML만 읽는다.
- `--year` 는 실록 title metadata의 왕대 연차를 accession year와 합쳐 서기 연도로 변환한 뒤 필터링한다.
- `--king``세종`, `세종실록` 같은 입력을 canonical king label로 정규화한다.
- 결과가 적으면 detail page를 바로 열어 excerpt를 붙이는 편이 가장 단순하고 안정적이다.
## 라이브 검증 메모
2026-04-03 기준 live smoke run에서 `훈민정음` 검색으로 실결과가 반환되었고, `--king 세종 --year 1443` 필터로 `kda_12512030_002` 기사(「훈민정음을 창제하다」)를 안정적으로 다시 찾을 수 있었다.

View file

@ -12,11 +12,14 @@
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래 둘입니다.
현재 기본 엔드포인트는 아래와 같습니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/opinet/around`
- `GET /v1/opinet/detail`
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
## 권장 환경변수
@ -29,9 +32,39 @@ client/skill -> k-skill-proxy -> upstream public API
- `AIR_KOREA_OPEN_API_KEY=...`
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## PM2 + cloudflared
## 프로덕션 배포 구조
프로덕션 proxy 서버는 개발 repo와 분리된 별도 clone으로 운영한다.
- 배포 디렉토리: `~/.local/share/k-skill-proxy` (main 브랜치 단독 clone)
- PM2 프로세스: `k-skill-proxy`
- Cloudflare Tunnel ingress: `k-skill-proxy.nomadamas.org -> http://localhost:4020`
### 자동 배포 (cron)
`~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh`가 매시 정각에 실행된다.
```
0 * * * * PATH=/usr/bin:/opt/homebrew/bin:/opt/homebrew/lib/node_modules/.bin:$PATH ~/.local/share/k-skill-proxy/scripts/auto-update-proxy.sh >> /tmp/k-skill-proxy-update.log 2>&1
```
동작 순서:
1. `git fetch origin main`
2. local SHA == remote SHA 이면 종료 (up-to-date)
3. `git pull --ff-only`
4. `package-lock.json` 변경 시 `npm ci`
5. `pm2 restart k-skill-proxy --update-env`
따라서 **main에 merge되어야 프로덕션에 반영**된다. dev 브랜치 변경은 프로덕션에 영향 없음.
로그: `/tmp/k-skill-proxy-update.log`
### 초기 설정 (PM2 + cloudflared)
1. `pm2 start ecosystem.config.cjs`
2. `pm2 save`
@ -63,6 +96,32 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한강 수위 정보 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
이 endpoint 는 내부적으로 HRFCO `waterlevel/info.json` 으로 관측소를 찾고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 10분 수위/유량을 가져옵니다.
Opinet 근처 주유소 가격 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/around' \
--data-urlencode 'x=313680' \
--data-urlencode 'y=545015' \
--data-urlencode 'radius=1500' \
--data-urlencode 'prodcd=B027'
```
Opinet 주유소 상세 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/detail' \
--data-urlencode 'id=A0009905'
```
AirKorea passthrough endpoint:
```bash

View file

@ -0,0 +1,97 @@
# 한국어 맞춤법 검사 가이드
## 이 기능으로 할 수 있는 일
- 바른한글(구 부산대 맞춤법/문법 검사기)로 한국어 문장 최종 교정
- 긴 텍스트를 청크 단위로 나눠 순차 검사
- 오류별 `원문`, `교정안`, `이유` 추출
- Markdown/텍스트 파일 검수
## 먼저 알아둘 점
- 공개 메인 화면은 `https://nara-speller.co.kr/speller/` 이다.
- 무료 공개 경로는 사용자용 웹 서비스이며, 문서화된 공개 JSON API는 확인하지 못했다.
- 이전 버전 `https://nara-speller.co.kr/old_speller/`**비상업적 용도**, **개인이나 학생만 무료**라는 안내를 표시한다.
- `https://nara-speller.co.kr/robots.txt``/` 를 허용하지만 `/test_speller/` 는 금지한다.
- 따라서 이 기능은 **사용자 주도 저빈도 교정** 용도로만 사용한다.
## 현재 구현 방식
이 저장소는 브라우저형 헤더를 사용하는 Python stdlib helper `scripts/korean_spell_check.py` 를 제공한다.
- 검사 요청: `POST https://nara-speller.co.kr/old_speller/results`
- 응답 형식: 결과 HTML 내부의 `data = [...]` payload
- 출력 형식: 교정문 + 이슈 목록(JSON 또는 텍스트)
이 환경에서는 일반 shell/Node fetch가 Cloudflare 때문에 `403` 을 반환할 수 있었고, Python `urllib` + 브라우저형 User-Agent 조합은 실제 결과 HTML을 반환했다. 그래서 helper는 Python 경로를 기본값으로 사용한다.
## 사용 예시
짧은 문장:
```bash
python3 scripts/korean_spell_check.py \
--text "아버지가방에들어가신다." \
--format text
```
Markdown 파일:
```bash
python3 scripts/korean_spell_check.py \
--file README.md \
--max-chars 1500 \
--throttle-seconds 1.2 \
--format json
```
## 출력 예시
```json
{
"corrected_text": "아버지가 방에 들어가신다.",
"issues": [
{
"original": "아버지가방에들어가신다",
"suggestions": ["아버지가 방에 들어가신다"],
"reason": "띄어쓰기, 붙여쓰기, 음절 대치와 같은 교정 방법에 따라 수정한 결과입니다."
}
]
}
```
## 긴 텍스트 운영 규칙
1. 기본 청크는 `1500` 자 안팎으로 나눈다.
2. 빈 줄 기준 문단 경계를 먼저 보존한다.
3. 문단이 너무 길면 문장 단위로 다시 나눈다.
4. 청크 사이에 `1초` 내외 대기 시간을 둔다.
5. 대량 파일 일괄 실행이나 병렬 폭주는 피한다.
## 응답 정리 규칙
- 전체 교정문을 먼저 보여준다.
- 그다음 변경점 목록을 붙인다.
- 각 변경점은 `원문 → 교정안` 형태로 보여준다.
- 가능한 경우 `이유` 또는 도움말 문장을 함께 넣는다.
예:
- 원문: `왠지 않되요`
- 교정안: `왠지 안 돼요`
- 이유: 띄어쓰기/활용형 오류 교정
## 라이브 확인 메모
2026-04-03 기준으로 아래 사실을 확인했다.
- `GET https://nara-speller.co.kr/speller/` 는 브라우저형 User-Agent에서 정상 HTML을 반환했다.
- `GET https://nara-speller.co.kr/old_speller/` 는 브라우저형 User-Agent에서 정상 HTML을 반환했다.
- `GET https://nara-speller.co.kr/robots.txt``/` 허용, `/test_speller/` 금지를 반환했다.
- `POST https://nara-speller.co.kr/old_speller/results``text1=아버지가방에들어가신다.` 를 보내면 `아버지가 방에 들어가신다` 교정 후보가 포함된 결과 HTML을 반환했다.
## 주의 사항
- 서비스 안내상 비상업적/개인·학생 무료 경로이므로, 상업적 대량 자동화에는 사용하지 않는다.
- 외부 웹 서비스에 원문이 전송되므로 민감정보/개인정보 문서는 먼저 마스킹 여부를 판단한다.
- 공개 웹 검사기 결과도 문맥에 따라 사람이 최종 확인해야 한다.

View file

@ -0,0 +1,106 @@
# LCK 경기 분석
`lck-analytics` 는 Riot 공식 LoL Esports 표면과 Oracle's Elixir 스타일 historical 데이터셋을 함께 사용해 LCK 경기 결과와 고급 분석을 제공하는 스킬이다.
## Origin / attribution
- Original reference skill: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- k-skill adaptation: 이 저장소의 npm workspace / Changesets 배포 흐름에 맞춰 패키지와 문서를 정리한 버전
## 무엇을 할 수 있나
- 날짜별 LCK 경기 결과 조회
- 팀 alias 정규화 (`한화`, `HLE`, `SKT T1`, `DN FREECS`, `광동 프릭스` 등)
- 해당 날짜 기준 현재 순위 조회
- live game 킬 / 골드 / 오브젝트 / participant snapshot 조회
- live timeline 기반 turning point 추정
- Oracle's Elixir 스타일 CSV로부터
- 팀 파워 레이팅
- champion matchup / synergy
- patch meta summary 계산
## 설치
```bash
npm install -g lck-analytics
export NODE_PATH="$(npm root -g)"
```
## 기본 조회 예시
```bash
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
import path from "node:path";
import { pathToFileURL } from "node:url";
const entry = pathToFileURL(
path.join(process.env.GLOBAL_NPM_ROOT, "lck-analytics", "src", "index.js"),
).href;
const { getLckSummary } = await import(entry);
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
## Local pipeline
skill directory 안에는 원본 pack을 따라 local helper script도 포함한다.
- `lck-analytics/scripts/sync-oracle.js`
- `lck-analytics/scripts/build-match-report.js`
- `lck-analytics/scripts/analyze-live-game.js`
- `lck-analytics/samples/oracle-lck-sample.csv`
historical cache 생성:
```bash
node ./lck-analytics/scripts/sync-oracle.js \
--csv ./lck-analytics/samples/oracle-lck-sample.csv
```
날짜별 match analysis 생성:
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01 \
--team 한화
```
live turning point 분석:
```bash
node ./lck-analytics/scripts/analyze-live-game.js \
--game game-id
```
## Official surfaces
- `https://esports-api.lolesports.com/persisted/gw/getSchedule`
- `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
- `https://esports-api.lolesports.com/persisted/gw/getStandings`
- `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
- `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- `https://feed.lolesports.com/livestats/v1/details/{gameId}`
- Oracle's Elixir downloads / schema reference: <https://oracleselixir.com/tools/downloads>
## Release / publish note
이 기능은 `packages/lck-analytics` workspace로 추가됐다. 따라서 `main` 에 기능 PR이 merge되면:
1. `.changeset/*.md` 가 Version Packages PR을 생성하고
2. 그 PR merge 뒤
3. npm publish workflow가 `lck-analytics` 패키지를 배포한다
즉, main merge 직후 바로 태그를 수동으로 만들지 말고 기존 Changesets 릴리스 흐름을 따른다.
## Caveats
- Riot web app용 공개 API key fallback은 회전될 수 있으므로, 필요하면 `LOLESPORTS_API_KEY` 환경변수로 override한다
- turning point는 live snapshot 기반 heuristic 이라 VOD/GRID 레벨 정밀 분석과 동일하지 않다
- historical 분석 품질은 Oracle-style row sample 수에 크게 좌우된다

View file

@ -0,0 +1,146 @@
# 올리브영 검색 가이드
## 이 기능으로 할 수 있는 일
원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 사용해 올리브영 **매장 검색, 상품 검색, 재고 확인**을 한다.
- `/api/oliveyoung/stores` 로 매장 검색
- `/api/oliveyoung/products` 로 상품 검색
- `/api/oliveyoung/inventory` 로 재고 확인
- `npx --yes daiso health` 로 endpoint health 확인
## 가장 중요한 규칙
이 기능은 upstream 원본을 그대로 쓴다.
`k-skill` 안에 별도 올리브영 수집기를 추가하지 않고, **MCP 서버를 Claude Code에 직접 설치하지 않고 CLI로 먼저 검증하는 경로**를 기본으로 둔다.
즉, 원본 서버 코드를 이 저장소에 **vendoring 하지 않고** skill/docs 가이드만 유지한다.
즉 기본 경로는 아래 둘 중 하나다.
1. `npx --yes daiso ...`
2. `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build`
## 먼저 필요한 것
- 인터넷 연결
- `node` 20 권장
- `npx` 또는 `npm`
- 필요하면 `git`
2026-04-05 기준 upstream `package.json``engines.node``>=20 <21` 이다.
로컬 Node 22 환경에서도 smoke test는 통과했지만 `EBADENGINE` 경고가 있었으므로, 운영 가이드는 Node 20 LTS 중심으로 적는다.
## 가장 빠른 시작: npx CLI
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g daiso
export NODE_PATH="$(npm root -g)"
daiso health
```
## 원본 저장소 clone fallback
public endpoint 재시도, 버전 고정, 원본 확인이 필요하면 아래처럼 clone 후 build 결과물 `dist/bin.js``node` 로 직접 실행한다.
clone checkout 안에서는 `npx daiso ...``Permission denied` 로 실패할 수 있으므로 이 경로를 기본으로 적는다.
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
## 입력값 권장 순서
1. **지역/매장 키워드**
- 예: `명동`, `강남역`, `성수`
2. **상품 키워드**
- 예: `선크림`, `립밤`, `마스크팩`
재고 질문인데 지역/매장 키워드가 없으면 먼저 지역을 보강한다.
상품 종류를 묻는 경우에는 먼저 `/api/oliveyoung/products` 로 후보를 보여주고, 재고 확인이 필요할 때 `/api/oliveyoung/inventory` 로 내려간다.
## 기본 흐름
### 1. health 확인
```bash
npx --yes daiso health
```
### 2. 매장 검색
```bash
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
```
### 3. 상품 검색
```bash
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
```
응답에서는 `goodsNumber`, `goodsName`, `priceToPay`, `imageUrl`, `inStock` 를 먼저 본다.
### 4. 재고 확인
```bash
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
재고 응답에서는 `inventory.products[].storeInventory.stores[]` 안의 아래 필드를 우선 해석한다.
- `stockLabel` (`재고 9개 이상`, `품절`, `미판매` 등)
- `remainQuantity`
- `stockStatus`
- `storeName`
## 응답 정리 원칙
- 매장 후보가 많으면 상위 2~3개만 먼저 제시한다.
- 상품 후보가 많으면 가격, 이미지 URL, `inStock` 여부를 붙여 상위 3~5개만 요약한다.
- 재고는 `재고 있음 / 품절 / 미판매` 를 매장별로 분리해서 쓴다.
- `imageUrl` 이 있으면 query string(`?l=ko`)을 지우지 않는다.
- 공개 endpoint 특성상 방문 직전 재확인을 권한다.
## 라이브 확인 메모
2026-04-05 기준 아래 흐름을 실제로 실행해 응답을 확인했다.
- `npx --yes daiso health``status: ok`, endpoint `https://mcp.aka.page/mcp`
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 3 --json`
- `명동타임워크점`, `명동2가점`, `올리브영 명동 타운` 등 매장 후보 확인
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 3 --json`
- `totalCount: 435`, `imageUrl`, `priceToPay`, `inStock` 포함 상품 후보 확인
- local clone + build 후 `node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 3 --json`
- `stockLabel: 재고 9개 이상 / 품절 / 미판매`, `remainQuantity`, `storeName` 확인
같은 날짜에 public `npx --yes daiso get /api/oliveyoung/stores ...` 는 한 차례 `Zyte API 호출 실패: 503 Service Unavailable` 를 반환했다. 그래서 문서 기본 경로는 여전히 CLI first 이지만, **재시도 또는 clone fallback** 을 함께 안내한다.
## 제한사항
- public endpoint는 upstream 수집 인프라 상태에 따라 간헐적 5xx/503이 날 수 있다.
- 넓은 지역 키워드는 먼 지점까지 섞일 수 있다.
- 재고 수량은 실시간 100% 보장값이 아니다.
- 주문/결제 자동화는 다루지 않는다.
## 참고 링크
- 원본 repo: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- Olive Young stores API: `https://mcp.aka.page/api/oliveyoung/stores`
- Olive Young products API: `https://mcp.aka.page/api/oliveyoung/products`
- Olive Young inventory API: `https://mcp.aka.page/api/oliveyoung/inventory`

View file

@ -0,0 +1,63 @@
# 한국 부동산 실거래가 조회 가이드
## 이 기능으로 할 수 있는 일
- 아파트 매매 실거래가 조회 (`/v1/real-estate/apartment/trade`)
- 아파트 전월세 조회 (`/v1/real-estate/apartment/rent`)
- 오피스텔/연립다세대/단독주택/상업업무용 실거래가 조회
- 지역코드 조회 (`/v1/real-estate/region-code`) 후 행정구역 기준 검색
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/real-estate/...` 이다.
사용자는 별도 API key를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
## 먼저 필요한 것
없음. 인터넷 연결만 있으면 된다.
## 지역코드 검색
주소/행정구역이 애매하면 먼저 지역코드를 검색한다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/region-code' \
--data-urlencode 'q=마포구'
```
응답에서 `lawd_cd` 를 확인한 후 실거래가 조회에 사용한다.
## 실거래가/전월세 조회
```bash
# 아파트 매매
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/trade' \
--data-urlencode 'lawd_cd=11440' \
--data-urlencode 'deal_ymd=202403'
# 아파트 전월세
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/rent' \
--data-urlencode 'lawd_cd=11440' \
--data-urlencode 'deal_ymd=202403'
# 오피스텔 매매
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/officetel/trade' \
--data-urlencode 'lawd_cd=11680' \
--data-urlencode 'deal_ymd=202403'
```
지원하는 자산 타입: `apartment`, `officetel`, `villa`, `single-house`, `commercial`
지원하는 거래 타입: `trade`, `rent` (commercial은 trade만)
## 조회 흐름 권장 순서
1. 주소/행정구역이 애매하면 `/v1/real-estate/region-code?q=...` 부터 호출한다.
2. 아파트 매매면 `apartment/trade`, 전월세면 `apartment/rent` 를 쓴다.
3. 오피스텔/빌라/단독주택/상업업무용은 해당 전용 endpoint로 분기한다.
4. 사용자가 연월을 안 줬으면 기준 월을 먼저 확인한다.
5. 실거래가와 호가를 섞지 말고, 신고 기반 데이터라는 점을 짧게 명시한다.
## 참고 링크
- 원본 MCP 서버 (참고용): `https://github.com/tae0y/real-estate-mcp/tree/main`
- 공식 데이터 출처: 공공데이터포털 (`https://www.data.go.kr`)

View file

@ -47,18 +47,25 @@ npx --yes skills add <owner/repo> \
--skill hwp \
--skill kbo-results \
--skill kleague-results \
--skill lck-analytics \
--skill toss-securities \
--skill lotto-results \
--skill kakaotalk-mac \
--skill korean-law-search \
--skill real-estate-search \
--skill joseon-sillok-search \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
--skill daiso-product-search \
--skill olive-young-search \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \
--skill coupang-product-search \
--skill used-car-price-search
--skill used-car-price-search \
--skill korean-spell-check
```
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
@ -69,6 +76,9 @@ npx --yes skills add <owner/repo> \
--skill srt-booking \
--skill ktx-booking \
--skill korean-law-search \
--skill real-estate-search \
--skill cheap-gas-nearby \
--skill joseon-sillok-search \
--skill seoul-subway-arrival \
--skill fine-dust-location
```
@ -87,6 +97,39 @@ korean-law list
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
### `olive-young-search` upstream CLI quickstart
`olive-young-search` 는 upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) / npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용한다.
- 기본 경로는 **MCP 서버 직접 설치가 아니라 CLI first** 다.
- 가장 빠른 smoke test 는 `npx --yes daiso health`
- 재고/매장/상품 조회는 `npx --yes daiso get /api/oliveyoung/...`
- public endpoint는 upstream 수집 상태에 따라 간헐적인 `5xx/503` 이 날 수 있으니 먼저 한두 번 재시도한다.
- 반복 사용이면 `npm install -g daiso`
- 재시도 후에도 불안정하거나 버전 고정/원본 확인이 필요하면 `git clone https://github.com/hmmhmmhm/daiso-mcp.git && cd daiso-mcp && npm install && npm run build` clone fallback으로 전환한 뒤 `node dist/bin.js ...` 로 실행한다. clone checkout 안에서 `npx daiso ...``Permission denied` 로 실패할 수 있다.
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
clone fallback 예시:
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
로컬 저장소에서 바로 전체 설치 테스트:
```bash
@ -121,7 +164,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp daiso
export NODE_PATH="$(npm root -g)"
```
@ -141,6 +184,18 @@ brew install tossctl
python3 -m pip install SRTrain korail2 pycryptodome
```
조선왕조실록 검색 helper는 설치된 `joseon-sillok-search` skill 안의 `scripts/sillok_search.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
python3 scripts/sillok_search.py --query "훈민정음" --king 세종 --year 1443
```
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다."
```
운영체제 정책이나 권한 때문에 전역 설치가 막히면, 임의의 대체 구현으로 넘어가지 말고 그 차단 사유를 사용자에게 설명한 뒤 다음 설치 단계를 정합니다.
## npx도 없으면
@ -160,6 +215,8 @@ python3 -m pip install SRTrain korail2 pycryptodome
- `seoul-subway-arrival`
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`
- `cheap-gas-nearby`
관련 문서:

View file

@ -8,18 +8,25 @@
- KTX
- KBO 경기 결과
- K리그 경기 결과 조회 스킬 출시
- LCK 경기 분석 스킬 출시
- 토스증권 조회 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
- 한국 부동산 실거래가 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 중고차 가격 조회 스킬 출시
- 한국어 맞춤법 검사 스킬 출시
## v1.5 candidates

View file

@ -29,7 +29,7 @@ AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지만 hosted proxy 로 쓸 때는 이 값을 비워 두고 skill 기본값을 써도 된다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -64,6 +64,6 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `AIR_KOREA_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY` 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격은 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -33,12 +33,16 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지만 쓴다면 이 값을 비워 두고 skill 기본 hosted path를 그대로 써도 된다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
## 확인
```bash
@ -60,8 +64,11 @@ bash scripts/check-setup.sh
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
## 다음에 볼 문서
@ -69,7 +76,10 @@ bash scripts/check-setup.sh
- [KTX 예매 가이드](features/ktx-booking.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -10,14 +10,37 @@
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
- jerjangmin original `lck-analytics` skill pack: https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics
- Riot LoL Esports schedule API: https://esports-api.lolesports.com/persisted/gw/getSchedule
- Riot LoL Esports tournaments API: https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague
- Riot LoL Esports standings API: https://esports-api.lolesports.com/persisted/gw/getStandings
- Riot LoL Esports event details API: https://esports-api.lolesports.com/persisted/gw/getEventDetails
- Riot LoL Esports live window feed: https://feed.lolesports.com/livestats/v1/window/<gameId>
- Riot LoL Esports live details feed: https://feed.lolesports.com/livestats/v1/details/<gameId>
- Oracle's Elixir data glossary: https://oracleselixir.com/tools/downloads
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
- MOLIT 아파트 매매 실거래가 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade
- MOLIT 아파트 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent
- MOLIT 오피스텔 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade
- MOLIT 오피스텔 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent
- MOLIT 연립다세대 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade
- MOLIT 연립다세대 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent
- MOLIT 단독/다가구 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade
- MOLIT 단독/다가구 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent
- MOLIT 상업업무용 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade
- beopmang: https://api.beopmang.org
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 바른한글 메인: https://nara-speller.co.kr/speller/
- 바른한글 사용법: https://nara-speller.co.kr/guide/
- 바른한글 이전 버전: https://nara-speller.co.kr/old_speller/
- 바른한글 이전 버전 결과 POST 표면: https://nara-speller.co.kr/old_speller/results
- 바른한글 robots: https://nara-speller.co.kr/robots.txt
- 다이소몰 매장 검색: https://www.daisomall.co.kr/api/ms/msg/selStr
- 다이소몰 매장 검색어 목록: https://www.daisomall.co.kr/api/ms/msg/selStrSrchKeyword
- 다이소몰 매장 상세: https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo
@ -26,6 +49,12 @@
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- olive-young / multi-retail upstream repo (`hmmhmmhm/daiso-mcp`): https://github.com/hmmhmmhm/daiso-mcp
- olive-young CLI package (`daiso`): https://www.npmjs.com/package/daiso
- olive-young stores API: https://mcp.aka.page/api/oliveyoung/stores
- olive-young products API: https://mcp.aka.page/api/oliveyoung/products
- olive-young inventory API: https://mcp.aka.page/api/oliveyoung/inventory
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
- 블루리본 메인: https://www.bluer.co.kr/
@ -33,9 +62,20 @@
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
- 조선왕조실록 메인: https://sillok.history.go.kr
- 조선왕조실록 검색 결과: https://sillok.history.go.kr/search/searchResultList.do
- 조선왕조실록 기사 상세: https://sillok.history.go.kr/id/kda_12512030_002
- Opinet 오픈 API 안내: https://www.opinet.co.kr/user/custapi/openApiInfo.do
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
- 한강홍수통제소 Open API 레퍼런스: https://www.hrfco.go.kr/web/openapiPage/reference.do
- 한강홍수통제소 Open API 인증키 안내: https://www.hrfco.go.kr/web/openapiPage/certifyKey.do
- 한강홍수통제소 Open API 정책: https://www.hrfco.go.kr/web/openapi/policy.do
- 한강홍수통제소 API base: https://api.hrfco.go.kr
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail

View file

@ -0,0 +1,93 @@
---
name: han-river-water-level
description: 한강홍수통제소 기반 현재 수위/유량을 관측소명 또는 관측소코드로 조회한다. 기본 경로는 k-skill-proxy의 han-river water-level endpoint다.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Han River Water Level
## What this skill does
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/han-river/water-level` 로 요청해서 현재 수위와 유량을 요약한다.
## When to use
- "한강대교 지금 수위 어때?"
- "잠수교 유량 알려줘"
- "1018683 관측소 현재 값 보여줘"
## Inputs
- 기본 입력: 관측소명/교량명(`stationName`)
- 대체 입력: 관측소코드(`stationCode`)
## Prerequisites
- optional: `jq`
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
사용자는 별도 HRFCO `ServiceKey` 를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Example requests
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
관측소코드로 바로 조회해도 된다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationCode=1018683'
```
## Keep the answer compact
응답에는 아래만 먼저 정리한다.
- 관측소명 / 관측소코드
- 관측 시각
- 현재 수위(m)
- 현재 유량(m^3/s)
- 기준 수위(관심/주의/경보/심각) 중 값이 있는 항목
## Ambiguous station names
입력이 너무 넓으면 proxy 는 `ambiguous_station` 과 함께 `candidate_stations` 를 돌려준다.
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/han-river/water-level' \
--data-urlencode 'stationName=한강'
```
이때는 후보 중 하나를 골라 다시 `stationName` 또는 `stationCode` 로 조회한다.
## Detailed API paths
구현 세부는 아래 문서만 참고한다.
- `docs/features/han-river-water-level.md`
- `docs/features/k-skill-proxy.md`
## Failure modes
- 관측소명이 너무 넓어서 여러 관측소가 동시에 잡히는 경우
- 잘못된 관측소코드/관측소명으로 station lookup 이 실패하는 경우
- 프록시 서버에 `HRFCO_OPEN_API_KEY` 가 비어 있는 경우
- 실시간 자료 갱신 지연으로 최신 10분 자료가 비어 있는 경우
## Notes
- 기본 경로는 항상 `k-skill-proxy.nomadamas.org` 의 water-level endpoint 다.
- upstream 은 `waterlevel/info.json` 으로 관측소 메타데이터를 찾고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신값을 조회한다.
- 결과는 원시자료 기반이므로 조회 시각을 함께 적는다.

View file

@ -0,0 +1,86 @@
---
name: joseon-sillok-search
description: Search Joseon Dynasty Annals records from the official sillok.history.go.kr site with keyword search plus optional king/year narrowing.
license: MIT
metadata:
category: history
locale: ko-KR
phase: v1
---
# 조선왕조실록 검색
## What this skill does
국사편찬위원회 조선왕조실록 사이트(`https://sillok.history.go.kr`)에서 **공식 검색 결과 HTML과 기사 상세 페이지를 직접 읽어** 조선왕조실록 기록을 찾는다.
v1 범위는 단순 스크래핑이다.
- 키워드 검색
- 선택적 왕별 필터(`--king`)
- 선택적 서기 연도 필터(`--year`)
- 검색 결과 제목/요약/원문 링크 정리
- 기사 상세 페이지에서 국역/원문 excerpt 추출
## When to use
- "조선왕조실록에서 훈민정음 찾아줘"
- "세종 때 실록에서 측우기 관련 기사 검색해줘"
- "1443년 조선왕조실록 기록 찾아줘"
- "정조실록에서 수원 관련 기록 몇 개 보여줘"
## Prerequisites
- 인터넷 연결
- `python3`
- 별도 API 키 없음
- 설치된 skill payload 안에 `scripts/sillok_search.py` helper가 함께 들어 있다.
## Inputs
- 필수: 검색어
- 선택: 왕 이름 (`세종`, `정조`, `세종실록` 등)
- 선택: 서기 연도 (`1443` 같이 Gregorian year)
- 선택: 결과 수 (`--limit`)
- 선택: 검색 타입 (`--type k|w`)
- `k`: 국역 검색
- `w`: 원문 검색
## Workflow
1. `python3 scripts/sillok_search.py --query "..."` 로 공식 검색 endpoint를 호출한다.
2. 검색 결과 HTML에서 결과 수, 왕별 분류, 기사 링크, 요약을 파싱한다.
3. 필요하면 `--king`, `--year` 로 결과를 추가로 좁힌다.
4. 선택된 기사마다 `/id/<article_id>` 상세 페이지를 열어 국역/원문 excerpt를 가져온다.
5. 구조화된 JSON으로 반환한다.
## CLI examples
```bash
python3 scripts/sillok_search.py --query "훈민정음"
python3 scripts/sillok_search.py --query "훈민정음" --king "세종" --year 1443 --limit 3
python3 scripts/sillok_search.py --query "측우기" --king "세종실록" --limit 5
python3 scripts/sillok_search.py --query "임진왜란" --type w --limit 5
```
## Response policy
- 결과는 공식 실록 사이트에서 확인한 **기사 제목 + 링크 + 요약 + 상세 excerpt** 중심으로 답한다.
- `--year` 는 서기 연도 기준으로 필터링한다.
- 입력한 왕 이름은 `세종`, `세종실록`처럼 조금 달라도 canonical 왕명으로 정규화한다.
- v1 에서는 semantic search, embedding, 대규모 색인 구축을 하지 않는다.
- 결과가 없으면 억지로 추정하지 말고 빈 결과를 그대로 알려준다.
## Done when
- 공식 사이트에서 실제 검색 결과가 1건 이상 조회되었다.
- 필요 시 왕/연도 필터가 적용되었다.
- 적어도 하나 이상의 기사 detail excerpt가 포함되었다.
- 링크가 `https://sillok.history.go.kr/id/...` 형태로 정리되었다.
## Notes
- 공식 메인: `https://sillok.history.go.kr`
- 검색 endpoint: `https://sillok.history.go.kr/search/searchResultList.do`
- 기사 상세: `https://sillok.history.go.kr/id/<article_id>`
- 이 저장소 v1 은 공개 HTML 표면만 사용한다.

View file

@ -0,0 +1,552 @@
from __future__ import annotations
import argparse
import html
import json
import math
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from http.cookiejar import CookieJar
from typing import Any, Iterable
try:
import requests
except ImportError: # pragma: no cover - optional runtime dependency
requests = None
BASE_URL = "https://sillok.history.go.kr"
SEARCH_URL = f"{BASE_URL}/search/searchResultList.do"
DETAIL_URL_TEMPLATE = f"{BASE_URL}/id/{{article_id}}"
DEFAULT_TIMEOUT = 30
DEFAULT_LIMIT = 5
MAX_PAGES = 20
DEFAULT_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
"Content-Type": "application/x-www-form-urlencoded",
"Origin": BASE_URL,
"Referer": f"{BASE_URL}/main/main.do",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
),
}
COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.S)
BR_PATTERN = re.compile(r"<br\s*/?>", re.I)
TAG_PATTERN = re.compile(r"<[^>]+>")
WHITESPACE_PATTERN = re.compile(r"\s+")
DIGITS_PATTERN = re.compile(r"\d+")
DETAIL_FOOTER_PATTERN = re.compile(
r"\s*(?:[【〖](?:태백산사고본|국편영인본|분류)[】〗]|ⓒ\s*세종대왕기념사업회).*",
re.S,
)
CATEGORY_PATTERN = re.compile(
r"<a\s+href=\"javascript:searchCategory\('([^']*)'\);\"\s+class=\"cate-item[^\"]*\">(.*?)</a>",
re.S,
)
RESULT_PATTERN = re.compile(
r"<div\s+class=\"result-box\">.*?"
r"<a\s+href=\"javascript:goView\('([^']+)',\s*\d+\);\"\s+class=\"subject\">(.*?)</a>\s*"
r"<p\s+class=\"text\">(.*?)</p>",
re.S,
)
TITLE_HEAD_PATTERN = re.compile(
r"<div\s+class=\"title\">\s*<p\s+class=\"date\">(.*?)</p>\s*<h3>(.*?)</h3>",
re.S,
)
LEFT_VIEW_PATTERN = re.compile(
r"<div\s+class=\"view-item\s+left\">.*?<div\s+class=\"view-text\">(.*?)</div>",
re.S,
)
RIGHT_VIEW_PATTERN = re.compile(
r"<div\s+class=\"view-item\s+right\">.*?<div\s+class=\"view-text\">(.*?)</div>",
re.S,
)
CLASSIFICATION_PATTERN = re.compile(r"<li\s+class=\"view_font02\">\s*[【〖]분류[】〗]\s*(.*?)</li>", re.S)
TOTAL_COUNT_PATTERN = re.compile(r"검색결과\s*<strong>(\d+)</strong>개")
HIDDEN_VALUE_TEMPLATE = '<input[^>]+id="{field}"[^>]+value="([^"]*)"'
KING_ACCESSION_YEARS = {
"태조": 1392,
"정종": 1399,
"태종": 1401,
"세종": 1418,
"문종": 1450,
"단종": 1452,
"세조": 1455,
"예종": 1468,
"성종": 1469,
"연산군": 1494,
"중종": 1506,
"인종": 1545,
"명종": 1545,
"선조": 1567,
"선조수정": 1567,
"광해군중초본": 1608,
"광해군정초본": 1608,
"인조": 1623,
"효종": 1649,
"현종": 1659,
"현종개수": 1659,
"숙종": 1674,
"숙종보궐정오": 1674,
"경종": 1720,
"경종수정": 1720,
"영조": 1724,
"정조": 1776,
"순조": 1800,
"헌종": 1834,
"철종": 1849,
"고종": 1863,
"순종": 1907,
"순종부록": 1910,
}
KING_ALIASES = {
"태조실록": "태조",
"정종실록": "정종",
"태종실록": "태종",
"세종실록": "세종",
"문종실록": "문종",
"단종실록": "단종",
"세조실록": "세조",
"예종실록": "예종",
"성종실록": "성종",
"연산군일기": "연산군",
"중종실록": "중종",
"인종실록": "인종",
"명종실록": "명종",
"선조실록": "선조",
"선조수정실록": "선조수정",
"광해군중초본": "광해군중초본",
"광해군정초본": "광해군정초본",
"인조실록": "인조",
"효종실록": "효종",
"현종실록": "현종",
"현종개수실록": "현종개수",
"숙종실록": "숙종",
"숙종보궐정오": "숙종보궐정오",
"경종실록": "경종",
"경종수정실록": "경종수정",
"영조실록": "영조",
"정조실록": "정조",
"순조실록": "순조",
"헌종실록": "헌종",
"철종실록": "철종",
"고종실록": "고종",
"순종실록": "순종",
"순종실록부록": "순종부록",
"순종부록": "순종부록",
}
for canonical in KING_ACCESSION_YEARS:
KING_ALIASES.setdefault(canonical, canonical)
@dataclass(frozen=True)
class SearchCategory:
label: str
count: int
token: str
@dataclass(frozen=True)
class ResultTitleMetadata:
king: str | None
regnal_year: int | None
gregorian_year: int | None
article_title: str
@dataclass(frozen=True)
class SearchResult:
article_id: str
url: str
title: str
article_title: str
summary: str
king: str | None
regnal_year: int | None
gregorian_year: int | None
@dataclass(frozen=True)
class SearchReport:
query: str
search_type: str
total_results: int
type_count: int
categories: list[SearchCategory]
items: list[SearchResult]
@dataclass(frozen=True)
class ArticleDetail:
article_id: str
url: str
header: str
title: str
translated_text: str
original_text: str
classification: str | None
def clean_text(value: str | None) -> str:
text = COMMENT_PATTERN.sub(" ", value or "")
text = BR_PATTERN.sub("\n", text)
text = TAG_PATTERN.sub(" ", text)
text = html.unescape(text)
return WHITESPACE_PATTERN.sub(" ", text).strip()
def clean_article_text(value: str | None) -> str:
text = COMMENT_PATTERN.sub(" ", value or "")
text = BR_PATTERN.sub("\n", text)
text = TAG_PATTERN.sub(" ", text)
text = html.unescape(text)
lines = [WHITESPACE_PATTERN.sub(" ", line).strip() for line in text.splitlines()]
text = "\n".join(line for line in lines if line)
text = DETAIL_FOOTER_PATTERN.sub("", text).strip()
return WHITESPACE_PATTERN.sub(" ", text).strip()
def positive_int(raw_value: str) -> int:
value = int(raw_value)
if value <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return value
def normalize_king_name(value: str | None) -> str | None:
if value is None:
return None
cleaned = clean_text(value).replace(" ", "")
if not cleaned:
return None
return KING_ALIASES.get(cleaned, cleaned)
def build_opener() -> urllib.request.OpenerDirector:
cookie_jar = CookieJar()
context = ssl.create_default_context()
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cookie_jar),
urllib.request.HTTPSHandler(context=context),
)
def build_http_client() -> Any:
return build_opener()
def should_fallback_to_opener(error: Exception) -> bool:
if requests is None:
return False
exceptions = getattr(requests, "exceptions", None)
http_error = getattr(exceptions, "HTTPError", None)
if isinstance(http_error, type) and isinstance(error, http_error):
return False
request_exception = getattr(exceptions, "RequestException", None)
if isinstance(request_exception, type) and isinstance(error, request_exception):
return True
return isinstance(error, OSError)
def fetch_text(
opener: Any,
url: str,
*,
data: dict[str, str] | None = None,
timeout: int = DEFAULT_TIMEOUT,
referer: str | None = None,
) -> str:
headers = dict(DEFAULT_HEADERS)
if referer is not None:
headers["Referer"] = referer
if requests is not None:
try:
if data is not None:
response = requests.post(url, data=data, timeout=timeout, headers=headers)
else:
response = requests.get(url, timeout=timeout, headers=headers)
response.raise_for_status()
return response.text
except Exception as error: # noqa: BLE001
if opener is None or not should_fallback_to_opener(error):
raise RuntimeError(f"Sillok request failed for {url}: {error}") from error
body = urllib.parse.urlencode(data).encode("utf-8") if data is not None else None
request = urllib.request.Request(url, data=body, headers=headers, method="POST" if body else "GET")
try:
with opener.open(request, timeout=timeout) as response:
return response.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as error: # type: ignore[attr-defined]
raise RuntimeError(f"Sillok request failed with HTTP {error.code} for {url}") from error
except urllib.error.URLError as error: # type: ignore[attr-defined]
raise RuntimeError(f"Sillok request failed for {url}: {error.reason}") from error
def extract_hidden_int(html_text: str, field: str) -> int | None:
pattern = re.compile(HIDDEN_VALUE_TEMPLATE.format(field=re.escape(field)))
match = pattern.search(html_text)
if not match:
return None
digits = DIGITS_PATTERN.search(match.group(1))
return int(digits.group(0)) if digits else None
def parse_result_title_metadata(title: str) -> ResultTitleMetadata:
cleaned_title = clean_text(title)
article_title = cleaned_title.split("/", 1)[1].strip() if "/" in cleaned_title else cleaned_title
metadata_match = re.search(r",\s*([^,]+?)\s+(즉위년|\d+년)\b", cleaned_title)
if not metadata_match:
return ResultTitleMetadata(None, None, None, article_title)
king = normalize_king_name(metadata_match.group(1))
year_token = metadata_match.group(2)
regnal_year = 1 if year_token == "즉위년" else int(year_token.removesuffix(""))
accession_year = KING_ACCESSION_YEARS.get(king or "")
if accession_year is None:
gregorian_year = None
elif year_token == "즉위년":
gregorian_year = accession_year
else:
gregorian_year = accession_year + regnal_year
return ResultTitleMetadata(king, regnal_year, gregorian_year, article_title)
def parse_search_results(html_text: str, *, query: str, search_type: str) -> SearchReport:
total_results = extract_hidden_int(html_text, "totalCount")
if total_results is None:
total_match = TOTAL_COUNT_PATTERN.search(html_text)
total_results = int(total_match.group(1)) if total_match else 0
count_field = {"k": "countK", "w": "countW", "m": "countM", "c": "countC"}.get(search_type, "")
type_count = extract_hidden_int(html_text, count_field) if count_field else None
if type_count is None:
type_count = total_results
categories: list[SearchCategory] = []
for token, label_html in CATEGORY_PATTERN.findall(html_text):
label = clean_text(label_html)
match = re.match(r"(.+?)\s*\((\d+)\)", label)
if not match:
continue
categories.append(SearchCategory(label=match.group(1), count=int(match.group(2)), token=token))
items: list[SearchResult] = []
for article_id, subject_html, summary_html in RESULT_PATTERN.findall(html_text):
title = clean_text(subject_html)
title = re.sub(r"^\d+\.\s*", "", title)
metadata = parse_result_title_metadata(title)
items.append(
SearchResult(
article_id=article_id,
url=DETAIL_URL_TEMPLATE.format(article_id=article_id),
title=title,
article_title=metadata.article_title,
summary=clean_text(summary_html),
king=metadata.king,
regnal_year=metadata.regnal_year,
gregorian_year=metadata.gregorian_year,
)
)
return SearchReport(
query=query,
search_type=search_type,
total_results=total_results,
type_count=type_count,
categories=categories,
items=items,
)
def filter_results(
items: Iterable[SearchResult],
*,
king: str | None = None,
year: int | None = None,
) -> list[SearchResult]:
normalized_king = normalize_king_name(king)
filtered: list[SearchResult] = []
for item in items:
if normalized_king is not None and item.king != normalized_king:
continue
if year is not None and item.gregorian_year != year:
continue
filtered.append(item)
return filtered
def parse_detail_page(html_text: str, *, article_id: str) -> ArticleDetail:
title_head = TITLE_HEAD_PATTERN.search(html_text)
if not title_head:
raise ValueError("Unable to find the article header on the detail page.")
translated_match = LEFT_VIEW_PATTERN.search(html_text)
original_match = RIGHT_VIEW_PATTERN.search(html_text)
if not translated_match or not original_match:
raise ValueError("Unable to find translated/original article text on the detail page.")
classification_match = CLASSIFICATION_PATTERN.search(html_text)
return ArticleDetail(
article_id=article_id,
url=DETAIL_URL_TEMPLATE.format(article_id=article_id),
header=clean_text(title_head.group(1)),
title=clean_text(title_head.group(2)),
translated_text=clean_article_text(translated_match.group(1)),
original_text=clean_article_text(original_match.group(1)),
classification=clean_text(classification_match.group(1)) if classification_match else None,
)
def build_search_payload(*, query: str, search_type: str, page_index: int) -> dict[str, str]:
return {
"topSearchWord": query,
"pageIndex": str(page_index),
"initPageUnit": "0",
"type": search_type,
"sillokType": "S",
"topSearchWord_ime": f'<span class="newbatang">{html.escape(query)}</span>',
}
def fetch_search_page(
opener: urllib.request.OpenerDirector,
*,
query: str,
search_type: str,
page_index: int,
timeout: int,
) -> SearchReport:
payload = build_search_payload(query=query, search_type=search_type, page_index=page_index)
html_text = fetch_text(opener, SEARCH_URL, data=payload, timeout=timeout, referer=f"{BASE_URL}/main/main.do")
return parse_search_results(html_text, query=query, search_type=search_type)
def fetch_detail_page(
opener: urllib.request.OpenerDirector,
*,
article_id: str,
timeout: int,
) -> ArticleDetail:
url = DETAIL_URL_TEMPLATE.format(article_id=article_id)
html_text = fetch_text(opener, url, timeout=timeout, referer=SEARCH_URL)
return parse_detail_page(html_text, article_id=article_id)
def search_sillok(
query: str,
*,
king: str | None = None,
year: int | None = None,
limit: int = DEFAULT_LIMIT,
search_type: str = "k",
timeout: int = DEFAULT_TIMEOUT,
) -> dict:
opener = build_http_client()
reports: list[SearchReport] = []
filtered_results: list[SearchResult] = []
page_index = 1
total_pages = 1
while page_index <= total_pages and page_index <= MAX_PAGES:
report = fetch_search_page(opener, query=query, search_type=search_type, page_index=page_index, timeout=timeout)
reports.append(report)
page_filtered = filter_results(report.items, king=king, year=year)
filtered_results.extend(page_filtered)
if page_index == 1:
page_size = len(report.items) or 1
total_pages = max(1, math.ceil((report.type_count or report.total_results or 0) / page_size))
if len(filtered_results) >= limit or not report.items:
break
page_index += 1
first_report = reports[0] if reports else SearchReport(query, search_type, 0, 0, [], [])
limited_results = filtered_results[:limit]
details = [fetch_detail_page(opener, article_id=item.article_id, timeout=timeout) for item in limited_results]
detail_map = {detail.article_id: detail for detail in details}
serialized_items = []
for item in limited_results:
detail = detail_map.get(item.article_id)
serialized_items.append(
{
**asdict(item),
"detail": asdict(detail) if detail else None,
"excerpt": detail.translated_text[:280] if detail and detail.translated_text else item.summary[:280],
}
)
return {
"query": query,
"type": search_type,
"filters": {"king": normalize_king_name(king), "year": year, "limit": limit},
"total_results": first_report.total_results,
"type_count": first_report.type_count,
"returned_count": len(serialized_items),
"categories": [asdict(category) for category in first_report.categories],
"items": serialized_items,
}
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Search Joseon Sillok records from sillok.history.go.kr")
parser.add_argument("--query", required=True, help="Search keyword to send to the Joseon Sillok site")
parser.add_argument("--king", help="Optional king filter, e.g. 세종 or 세종실록")
parser.add_argument("--year", type=positive_int, help="Optional Gregorian year filter, e.g. 1443")
parser.add_argument("--limit", type=positive_int, default=DEFAULT_LIMIT, help="Number of results to return")
parser.add_argument(
"--type",
dest="search_type",
choices=["k", "w"],
default="k",
help="Search translated text (k) or original text (w)",
)
parser.add_argument("--timeout", type=positive_int, default=DEFAULT_TIMEOUT, help="HTTP timeout in seconds")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
report = search_sillok(
args.query,
king=args.king,
year=args.year,
limit=args.limit,
search_type=args.search_type,
timeout=args.timeout,
)
except Exception as error: # noqa: BLE001
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -76,10 +76,15 @@ chmod 0600 ~/.config/k-skill/secrets.env
유저에게 물어서 실제 값을 채운다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지만 hosted proxy 로 쓸 때는 이 값을 비워 둘 수 있다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
### Missing secret response template
인증 스킬에서 값이 빠졌을 때는 credential resolution order에 따라 확보한다.
@ -90,6 +95,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`

115
korean-spell-check/SKILL.md Normal file
View file

@ -0,0 +1,115 @@
---
name: korean-spell-check
description: Use Nara/PNU Korean spell-check surfaces conservatively to proofread Korean text, chunk long input, and return change-focused correction suggestions.
license: MIT
metadata:
category: writing
locale: ko-KR
phase: v1
---
# Korean Spell Check
## What this skill does
국립국어원 계열 규칙을 반영한 **바른한글(구 부산대 맞춤법/문법 검사기)** 표면을 이용해 한국어 문장을 최종 교정한다.
- 기본 진입점은 공개 웹 표면 `https://nara-speller.co.kr/speller/` 이다.
- 자동화가 필요하면 이전 버전 폼 POST 표면 `https://nara-speller.co.kr/old_speller/results`**낮은 요청량으로만** 사용한다.
- 긴 글은 청크로 나눠 순차 검사한다.
- 결과는 `원문`, `교정안`, `이유` 중심으로 정리한다.
## Policy first
- `https://nara-speller.co.kr/old_speller/`**비상업적 용도** 안내와 **개인이나 학생만 무료**라는 문구를 명시한다.
- `https://nara-speller.co.kr/robots.txt``/` 를 허용하지만 `/test_speller/` 는 금지한다.
- 따라서 이 스킬은 **사용자 주도 최종 검수**, **저빈도 요청**, **문서/이메일/README 교정** 용도로만 쓴다.
- 대량 배치, SaaS 백엔드 연동, 상업 서비스 내 무단 재판매/재노출에는 쓰지 않는다. 그런 경우는 공급사 문의/유료 API 계약을 먼저 검토한다.
## When to use
- "이 한국어 문장 맞춤법 검사해줘"
- "README 한국어 문장 최종 검수해줘"
- "마크다운 파일 전체에서 띄어쓰기/맞춤법 오류를 잡아줘"
- "AI 교정보다 규칙 기반 한국어 검사기로 한 번 더 확인해줘"
## When not to use
- 코드 블록/로그/영문 위주 텍스트를 그대로 대량 전송해야 하는 경우
- 민감정보가 많은 원문을 외부 웹 서비스에 보내면 안 되는 경우
- 상업적 대량 처리 API가 필요한 경우
## Prerequisites
- 인터넷 연결
- `python3` 3.10+
- 이 스킬 디렉토리의 `scripts/korean_spell_check.py` (설치 시 자동 포함)
## Verified surface notes
- 현재 공개 사이트는 `https://nara-speller.co.kr/speller/` 로 제공된다.
- 이 환경에서 일반 shell/Node fetch는 Cloudflare 때문에 `403` 이 나올 수 있었다.
- 같은 환경에서도 **브라우저형 User-Agent + Python stdlib `urllib` POST**`old_speller/results` 에서 실제 검사 결과 HTML을 반환했다.
- 무료 공개 표면은 HTML 결과 페이지이며, 문서화된 공개 JSON API는 확인하지 못했다.
## Workflow
### 1. Ask for the text or file path
- 텍스트가 직접 주어지면 바로 검사한다.
- 파일 검사라면 UTF-8 텍스트/Markdown 파일만 대상으로 잡고, 코드 블록이 많으면 먼저 사용자에게 범위를 줄일지 물어보는 편이 안전하다.
### 2. Keep requests conservative
- 기본 청크 크기는 `1500` 자 안팎으로 유지한다.
- 청크 사이는 최소 `1초` 정도 쉬게 한다.
- 한 번에 너무 많은 파일을 돌리지 않는다.
### 3. Run the helper
```bash
python3 scripts/korean_spell_check.py \
--file README.md \
--format json
```
짧은 문장은 `--text` 로 바로 넣을 수 있다.
```bash
python3 scripts/korean_spell_check.py \
--text "아버지가방에들어가신다." \
--format text
```
### 4. Return change-focused output
최종 답변은 아래 순서를 권장한다.
1. 교정된 전체 문장/문단
2. 주요 변경점 목록
3. 각 변경점의 `원문`, `교정안`, `이유`
4. 필요하면 `공개 웹 검사기 기준 결과이며, 최종 문맥 판단은 사람이 확인` 문구
예시 JSON 필드:
```json
{
"original": "아버지가방에들어가신다",
"suggestions": ["아버지가 방에 들어가신다"],
"reason": "띄어쓰기, 붙여쓰기, 음절 대치와 같은 교정 방법에 따라 수정한 결과입니다."
}
```
## Done when
- 공개 표면 정책을 먼저 확인했다.
- 긴 텍스트면 청크 분할을 적용했다.
- 결과를 `원문/교정안/이유` 중심으로 정리했다.
- 고빈도/상업적 사용이 아님을 분명히 했다.
## Notes
- guide: `https://nara-speller.co.kr/guide/`
- main UI: `https://nara-speller.co.kr/speller/`
- old UI / form post: `https://nara-speller.co.kr/old_speller/`, `https://nara-speller.co.kr/old_speller/results`
- robots: `https://nara-speller.co.kr/robots.txt`

View file

@ -0,0 +1,523 @@
from __future__ import annotations
import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import asdict, dataclass
from html import unescape
from pathlib import Path
from typing import Callable
DEFAULT_RESULTS_URL = "https://nara-speller.co.kr/old_speller/results"
DEFAULT_MAX_CHARS = 1500
DEFAULT_TIMEOUT = 30
DEFAULT_THROTTLE_SECONDS = 1.2
RESULT_PAYLOAD_PATTERN = re.compile(r"data\s*=\s*(\[[\s\S]*?\]);\s*pageIdx\s*=")
NO_ISSUES_PATTERN = re.compile(r"맞춤법과\s*문법\s*오류를\s*찾지\s*못했습니다", re.MULTILINE)
TAG_PATTERN = re.compile(r"<[^>]+>")
LINE_BREAK_PATTERN = re.compile(r"<br\s*/?>", re.IGNORECASE)
SENTENCE_BOUNDARY_PATTERN = re.compile(r"(?<=[.!?。!?])\s+")
PARAGRAPH_SEPARATOR_PATTERN = re.compile(r"\n(?:[ \t]*\n)+")
DEFAULT_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",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://nara-speller.co.kr",
"Referer": "https://nara-speller.co.kr/old_speller/",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
),
}
@dataclass(frozen=True)
class SpellCheckIssue:
chunk_index: int
page_index: int
issue_index: int
sentence: str
original: str
suggestions: list[str]
reason: str
start: int | None
end: int | None
correct_method: int | None
error_message: str
def strip_html(value: str | None) -> str:
text = LINE_BREAK_PATTERN.sub("\n", value or "")
text = TAG_PATTERN.sub("", text)
return unescape(text).strip()
def split_candidates(value: str | None) -> list[str]:
return [candidate.strip() for candidate in str(value or "").split("|") if candidate.strip()]
def parse_positive_int(raw_value: str) -> int:
value = int(raw_value)
if value <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return value
def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHARS) -> list[str]:
original = str(text or "")
if not original.strip():
return []
units = split_paragraph_units(original)
chunks: list[str] = []
current = ""
for unit in units:
candidate = unit if not current else f"{current}{unit}"
if len(candidate) <= max_chars:
current = candidate
continue
if current:
chunks.append(current)
current = ""
if len(unit) <= max_chars:
current = unit
continue
separator = ""
body = unit
separator_match = PARAGRAPH_SEPARATOR_PATTERN.search(unit)
if separator_match and separator_match.end() == len(unit):
separator = separator_match.group(0)
body = unit[: separator_match.start()]
for sentence in split_long_paragraph(body, max_chars=max_chars):
if len(sentence) <= max_chars:
chunks.append(sentence)
continue
start = 0
while start < len(sentence):
chunks.append(sentence[start : start + max_chars])
start += max_chars
if separator:
if chunks and len(chunks[-1]) + len(separator) <= max_chars:
chunks[-1] += separator
else:
current = separator
if current:
chunks.append(current)
return chunks
def split_paragraph_units(text: str) -> list[str]:
units: list[str] = []
start = 0
for match in PARAGRAPH_SEPARATOR_PATTERN.finditer(text):
paragraph = text[start : match.start()]
separator = match.group(0)
if paragraph:
units.append(paragraph + separator)
elif units:
units[-1] += separator
else:
units.append(separator)
start = match.end()
tail = text[start:]
if tail:
units.append(tail)
return units
def split_long_paragraph(paragraph: str, *, max_chars: int) -> list[str]:
sentence_boundaries = list(SENTENCE_BOUNDARY_PATTERN.finditer(paragraph))
if not sentence_boundaries:
return [paragraph]
sentences: list[str] = []
start = 0
for boundary in sentence_boundaries:
sentences.append(paragraph[start : boundary.end()])
start = boundary.end()
if start < len(paragraph):
sentences.append(paragraph[start:])
groups: list[str] = []
current = ""
for sentence in sentences:
candidate = sentence if not current else f"{current}{sentence}"
if len(candidate) <= max_chars:
current = candidate
continue
if current:
groups.append(current)
current = sentence
if current:
groups.append(current)
return groups
def fetch_spell_check_html(
text: str,
*,
strong_rules: bool = True,
timeout: int = DEFAULT_TIMEOUT,
url: str = DEFAULT_RESULTS_URL,
) -> str:
body = {
"text1": text,
"chkKey": "",
}
if strong_rules:
body["btnModeChange"] = "on"
request = urllib.request.Request(
url,
data=urllib.parse.urlencode(body).encode("utf-8"),
headers=DEFAULT_HEADERS,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", "ignore")
except urllib.error.HTTPError as error: # type: ignore[attr-defined]
if error.code == 403:
raise RuntimeError(
"The spell-check service returned HTTP 403. "
"This environment may be hitting a Cloudflare/browser challenge. "
"Retry later with lower request volume or from a browser-friendly network."
) from error
raise RuntimeError(f"The spell-check service returned HTTP {error.code}.") from error
def extract_result_payload(html: str) -> list[dict]:
match = RESULT_PAYLOAD_PATTERN.search(html)
if not match:
if NO_ISSUES_PATTERN.search(html):
return []
raise ValueError("Unable to find the spell-check payload in the returned HTML.")
payload = json.loads(match.group(1))
if not isinstance(payload, list):
raise ValueError("The extracted spell-check payload was not a list.")
return payload
def apply_page_corrections(page: dict) -> str:
source = str(page.get("str", ""))
corrected = source
for error in sorted(page.get("errInfo", []), key=lambda item: int(item.get("start", -1)), reverse=True):
suggestions = split_candidates(error.get("candWord"))
original = str(error.get("orgStr", ""))
if not suggestions:
continue
start = int(error.get("start", -1))
end = int(error.get("end", -1))
if start < 0 or end < start or end >= len(source):
continue
slice_end = end + 1
if original:
while slice_end > start and source[start:slice_end] != original and source[start : slice_end - 1] == original:
slice_end -= 1
corrected = f"{corrected[:start]}{suggestions[0]}{corrected[slice_end:]}"
return corrected
def build_visible_text_index(text: str) -> tuple[str, list[int], list[int | None]]:
visible_chars: list[str] = []
visible_indices: list[int] = []
visible_lookup: list[int | None] = []
for index, char in enumerate(text):
if char.isspace():
visible_lookup.append(None)
continue
visible_lookup.append(len(visible_indices))
visible_chars.append(char)
visible_indices.append(index)
return "".join(visible_chars), visible_indices, visible_lookup
def preserve_original_layout(original: str, suggestion: str) -> str:
if "\n" not in original:
return suggestion
original_visible, original_visible_indices, _ = build_visible_text_index(original)
suggestion_visible, suggestion_visible_indices, _ = build_visible_text_index(suggestion)
if original_visible != suggestion_visible:
return suggestion
if not original_visible_indices or not suggestion_visible_indices:
return original if original.strip() else suggestion
merged: list[str] = []
leading_original = original[: original_visible_indices[0]]
leading_suggestion = suggestion[: suggestion_visible_indices[0]]
merged.append(leading_original if leading_original.isspace() else leading_suggestion)
for ordinal, suggestion_index in enumerate(suggestion_visible_indices):
merged.append(suggestion[suggestion_index])
next_original_index = original_visible_indices[ordinal + 1] if ordinal + 1 < len(original_visible_indices) else None
next_suggestion_index = (
suggestion_visible_indices[ordinal + 1] if ordinal + 1 < len(suggestion_visible_indices) else None
)
original_gap = (
original[original_visible_indices[ordinal] + 1 : next_original_index]
if next_original_index is not None
else original[original_visible_indices[ordinal] + 1 :]
)
suggestion_gap = (
suggestion[suggestion_index + 1 : next_suggestion_index]
if next_suggestion_index is not None
else suggestion[suggestion_index + 1 :]
)
merged.append(original_gap if "\n" in original_gap else suggestion_gap)
return "".join(merged)
def apply_chunk_corrections(chunk: str, pages: list[dict]) -> str:
combined_source = "".join(str(page.get("str", "")) for page in pages)
fallback = "".join(apply_page_corrections(page) for page in pages) or chunk
if not combined_source:
return fallback
chunk_visible, chunk_visible_indices, _ = build_visible_text_index(chunk)
source_visible, _, source_visible_lookup = build_visible_text_index(combined_source)
if chunk_visible != source_visible:
return fallback
replacements: list[tuple[int, int, str, str]] = []
page_offset = 0
for page in pages:
for error in page.get("errInfo", []):
suggestions = split_candidates(error.get("candWord"))
if not suggestions:
continue
start = int(error.get("start", -1))
end = int(error.get("end", -1))
if start < 0 or end < start:
continue
start += page_offset
end += page_offset
visible_ordinals = [
source_visible_lookup[index]
for index in range(start, min(end + 1, len(source_visible_lookup)))
if source_visible_lookup[index] is not None
]
if not visible_ordinals:
continue
original_start = chunk_visible_indices[visible_ordinals[0]]
original_end = chunk_visible_indices[visible_ordinals[-1]]
replacements.append((original_start, original_end, suggestions[0], str(error.get("orgStr", ""))))
page_offset += len(str(page.get("str", "")))
if not replacements:
return chunk
corrected = chunk
for start, end, suggestion, original in sorted(replacements, key=lambda item: item[0], reverse=True):
slice_end = end + 1
if original:
while (
slice_end > start
and corrected[start:slice_end] != original
and corrected[start : slice_end - 1] == original
):
slice_end -= 1
original_slice = corrected[start:slice_end]
replacement = preserve_original_layout(original_slice, suggestion)
corrected = f"{corrected[:start]}{replacement}{corrected[slice_end:]}"
return corrected
def build_issue(chunk_index: int, page_index: int, issue_index: int, page: dict, error: dict) -> SpellCheckIssue:
return SpellCheckIssue(
chunk_index=chunk_index,
page_index=page_index,
issue_index=issue_index,
sentence=str(page.get("str", "")),
original=str(error.get("orgStr", "")),
suggestions=split_candidates(error.get("candWord")),
reason=strip_html(error.get("help")) or strip_html(error.get("errMsg")),
start=int(error["start"]) if str(error.get("start", "")).strip() else None,
end=int(error["end"]) if str(error.get("end", "")).strip() else None,
correct_method=int(error["correctMethod"])
if str(error.get("correctMethod", "")).strip()
else None,
error_message=strip_html(error.get("errMsg")),
)
def check_text(
text: str,
*,
max_chars: int = DEFAULT_MAX_CHARS,
strong_rules: bool = True,
timeout: int = DEFAULT_TIMEOUT,
throttle_seconds: float = DEFAULT_THROTTLE_SECONDS,
requester: Callable[..., str] = fetch_spell_check_html,
sleep_fn: Callable[[float], None] = time.sleep,
) -> dict:
chunks = split_text_into_chunks(text, max_chars=max_chars)
corrected_chunks: list[str] = []
issues: list[SpellCheckIssue] = []
chunk_reports: list[dict] = []
for chunk_index, chunk in enumerate(chunks):
if chunk_index > 0 and throttle_seconds > 0:
sleep_fn(throttle_seconds)
html = requester(chunk, strong_rules=strong_rules, timeout=timeout)
pages = extract_result_payload(html)
corrected_chunk = apply_chunk_corrections(chunk, pages)
corrected_chunks.append(corrected_chunk)
chunk_reports.append(
{
"chunk_index": chunk_index,
"original_text": chunk,
"corrected_text": corrected_chunk,
"page_count": len(pages),
}
)
for page_index, page in enumerate(pages):
for issue_index, error in enumerate(page.get("errInfo", [])):
issues.append(build_issue(chunk_index, page_index, issue_index, page, error))
return {
"original_text": str(text or ""),
"corrected_text": "".join(corrected_chunks),
"chunks": chunk_reports,
"issues": issues,
"meta": {
"chunk_count": len(chunks),
"strong_rules": strong_rules,
"max_chars": max_chars,
},
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the official Nara/PNU Korean spell checker.")
parser.add_argument("--text", help="Inline Korean text to inspect.")
parser.add_argument("--file", help="UTF-8 text/markdown file to inspect.")
parser.add_argument("--max-chars", type=parse_positive_int, default=DEFAULT_MAX_CHARS)
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
parser.add_argument("--throttle-seconds", type=float, default=DEFAULT_THROTTLE_SECONDS)
parser.add_argument("--weak-rules", action="store_true", help="Disable the strong-rules checkbox.")
parser.add_argument("--format", choices=["json", "text"], default="json")
args = parser.parse_args(argv)
if not args.text and not args.file:
parser.error("Either --text or --file is required.")
return args
def load_input(args: argparse.Namespace) -> str:
if args.text:
return args.text
return Path(args.file).read_text(encoding="utf-8")
def serialize_report(report: dict) -> dict:
return {
**report,
"issues": [asdict(issue) for issue in report["issues"]],
}
def print_text_report(report: dict) -> None:
print("# corrected_text")
print(report["corrected_text"])
print()
print("# issues")
for issue in report["issues"]:
print(f"- chunk={issue.chunk_index} page={issue.page_index} issue={issue.issue_index}")
print(f" original: {issue.original}")
print(f" suggestions: {', '.join(issue.suggestions) if issue.suggestions else '(없음)'}")
print(f" reason: {issue.reason or '(없음)'}")
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
report = check_text(
load_input(args),
max_chars=args.max_chars,
strong_rules=not args.weak_rules,
timeout=args.timeout,
throttle_seconds=args.throttle_seconds,
)
if args.format == "json":
print(json.dumps(serialize_report(report), ensure_ascii=False, indent=2))
else:
print_text_report(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())

15
lck-analytics/README.md Normal file
View file

@ -0,0 +1,15 @@
# LCK Analytics skill pack
k-skill 버전의 `lck-analytics` 스킬 팩입니다.
- Original source: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- This repo adaptation: npm workspace / Changesets 릴리스 흐름에 맞춘 k-skill 배포용 패키징
포함 항목:
- `SKILL.md`: 에이전트에 바로 줄 수 있는 스킬 문서
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
- `scripts/analyze-live-game.js`: live game analysis 생성
- `samples/oracle-lck-sample.csv`: local smoke test용 샘플 CSV

202
lck-analytics/SKILL.md Normal file
View file

@ -0,0 +1,202 @@
---
name: lck-analytics
description: Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical 데이터로 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup/synergy, patch meta, 팀 파워 레이팅을 조회한다.
license: MIT
metadata:
category: sports
locale: ko-KR
phase: v1
---
# LCK Results + Advanced Analysis
## What this skill does
이 스킬은 LCK 조회/분석 전용이다.
- 특정 날짜 LCK 경기 결과 조회
- 특정 팀 alias 정규화 후 필터링
- 현재 스플릿 순위 조회
- 진행 중 경기 live stats 조회
- live timeline 기반 turning point 분석
- Oracle's Elixir 스타일 historical row / CSV 기반
- 팀 파워 레이팅
- 챔피언 matchup / synergy 분석
- patch meta 요약
- 날짜별 match analysis 생성
## Origin / attribution
이 스킬은 `jerjangmin` 님이 만든 원본 [`lck-analytics` skill pack](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill 저장소 안으로 옮기고, 이 저장소의 npm workspace / Changesets 배포 방식에 맞게 정리한 버전이다.
## When to use
- "오늘 LCK 경기 결과 알려줘"
- "2026-04-01 한화 경기 결과랑 순위 보여줘"
- "지금 T1 경기 킬/골드/오브젝트 요약해줘"
- "이 경기 turning point가 뭐였어?"
- "이 밴픽에서 어느 쪽 조합이 더 좋았는지 설명해줘"
- "현재 패치에서 어떤 챔피언이 메타 픽인지 보여줘"
- "LCK 팀 파워 레이팅 보여줘"
## Prerequisites
- Node.js 18+
- `npm install -g lck-analytics`
패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치를 시도한다.
```bash
npm install -g lck-analytics
```
## Inputs
### 기본 입력
- 날짜: `YYYY-MM-DD`
- 선택 사항: 팀명, 과거 팀명, 한글/영문 약칭 alias
### 고급 분석 입력
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열
- game id / match id
- live window/details payload 또는 실시간 fetch 권한
- patch version
## Team alias normalization
다음 이름들은 같은 canonical team 으로 인식한다.
- `DN SOOPers`
- `DN FREECS`
- `광동 프릭스`
- `Afreeca Freecs`
추가로 `T1`, `SKT T1`, `담원`, `Dplus KIA`, `브리온`, `한화`, `젠지`, `피어엑스` 등도 alias 정규화를 지원한다.
## Official surfaces
이 스킬은 Riot 공식 / 공식 웹앱 표면을 우선 사용한다.
- 일정/결과: `getSchedule`
- 토너먼트 목록: `getTournamentsForLeague`
- 순위: `getStandings`
- 이벤트 상세: `getEventDetails`
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
historical 고급 분석은 Oracle's Elixir 스타일 데이터 입력을 사용한다.
## Workflow
### Included lightweight local pipeline
이 k-skill 팩에는 경량 로컬 파일 기반 파이프라인 스크립트가 포함된다.
- `scripts/sync-oracle.js`: Oracle-style CSV → historical cache JSON
- `scripts/build-match-report.js`: 날짜별 match analysis 생성
- `scripts/analyze-live-game.js`: game analysis 생성
- 기본 cache 위치: `.openclaw-lck-cache/`
### 1. Basic scoreboard / standings query
```bash
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
import path from "node:path";
import { pathToFileURL } from "node:url";
const entry = pathToFileURL(
path.join(process.env.GLOBAL_NPM_ROOT, "lck-analytics", "src", "index.js"),
).href;
const { getLckSummary } = await import(entry);
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
console.log(JSON.stringify(summary, null, 2));
JS
```
### 2. Historical analytics from Oracle-style CSV
직접 API를 호출해도 되지만, local skill pipeline에서는 아래 스크립트 사용을 우선 권장한다.
```bash
node ./lck-analytics/scripts/sync-oracle.js \
--csv ./lck-analytics/samples/oracle-lck-sample.csv
```
### 3. Match analysis via local pipeline script
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01
```
필요하면 팀 필터도 같이 준다.
```bash
node ./lck-analytics/scripts/build-match-report.js \
--date 2026-04-01 \
--team 한화
```
### 4. Game analysis with turning points via local pipeline script
```bash
node ./lck-analytics/scripts/analyze-live-game.js \
--game game-id
```
fixture 기반으로 분석할 때는 `--window`, `--details` 를 같이 줄 수 있다.
## Output guidelines
사용자에게는 원본 JSON을 길게 그대로 던지지 말고 먼저 아래 순서로 정리한다.
### 경기 결과 요청
- 경기 시각
- 팀1 vs 팀2
- 상태
- 세트 스코어
- 요청 팀 경기만 있으면 해당 경기 우선
- standings 요청이 있으면 현재 순위 같이 표시
### 진행 중 경기 요청
- 현재 게임 번호
- 킬 차이
- 골드 차이
- 드래곤/바론/타워 차이
- turning point 1~3개
### historical / meta 요청
- sample 수를 먼저 표시
- 팀 파워 레이팅은 상위 팀부터 정렬
- champion matchup / synergy는 표본 수가 적으면 낮은 확신도로 표시
- patch meta는 top picks / risers 위주로 짧게 요약
## Done when
- 날짜 기준 경기 요약이 있다
- 요청 팀 필터가 적용된다
- standings 요청이면 현재 순위가 같이 정리된다
- live 요청이면 현재 게임 요약과 turning point가 있다
- historical 입력이 있으면 patch meta 또는 power rating까지 설명할 수 있다
## Failure modes
- Riot 웹앱 API 구조/헤더가 바뀌면 패키지 수정이 필요할 수 있다
- `LOLESPORTS_API_KEY` public fallback이 회전되면 환경변수 override가 필요할 수 있다
- historical CSV 컬럼명이 너무 다르면 Oracle-style 정규화 전에 전처리가 필요할 수 있다
## Notes
- 이 스킬은 조회/분석 전용이다
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
- 이 저장소에서 `main` 으로 머지되면 Changesets가 Version Packages PR을 만들고, 그 PR이 merge된 뒤 npm publish가 실행된다

View file

@ -0,0 +1,5 @@
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
1 league matchid date patch side teamname opponentteam playername position champion opponentchampion result gd15 csd15 xpd15 drg bn blindpick counterpick
2 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Zeus top Aatrox Gnar win 1200 18 340 100 100 0 1
3 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Peanut jungle Vi Sejuani win 800 5 280 100 100 1 0
4 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Doran top Gnar Aatrox loss -1200 -18 -340 0 0 1 0
5 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Oner jungle Sejuani Vi loss -800 -5 -280 0 0 0 1

103
lck-analytics/scripts/_lib.js Executable file
View file

@ -0,0 +1,103 @@
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
async function loadLckResults() {
const candidates = [];
const packageNames = ["lck-analytics", "lck-results"];
if (process.env.GLOBAL_NPM_ROOT) {
for (const packageName of packageNames) {
candidates.push(path.join(process.env.GLOBAL_NPM_ROOT, packageName, "src", "index.js"));
}
}
try {
const globalRoot = await detectGlobalNpmRoot();
for (const packageName of packageNames) {
candidates.push(path.join(globalRoot, packageName, "src", "index.js"));
}
} catch {
// ignore detection failure and continue to local fallback
}
candidates.push(path.resolve(__dirname, "..", "..", "packages", "lck-analytics", "src", "index.js"));
const entryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!entryPath) {
throw new Error("Could not find lck-analytics package. Install it globally with `npm install -g lck-analytics` or run from the k-skill repo.");
}
return import(pathToFileURL(entryPath).href);
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function readJson(filePath, fallback = null) {
if (!fs.existsSync(filePath)) {
return fallback;
}
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJson(filePath, value) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function readText(filePath, fallback = "") {
if (!fs.existsSync(filePath)) {
return fallback;
}
return fs.readFileSync(filePath, "utf8");
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
continue;
}
const key = token.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = true;
continue;
}
args[key] = next;
index += 1;
}
return args;
}
function formatOutput(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function resolveCachePaths(baseDir) {
return {
root: baseDir,
historical: path.join(baseDir, "historical-analysis.json"),
live: path.join(baseDir, "live"),
reports: path.join(baseDir, "reports"),
};
}
async function detectGlobalNpmRoot() {
const { execFileSync } = require("node:child_process");
return execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim();
}
module.exports = {
ensureDir,
formatOutput,
loadLckResults,
parseArgs,
readJson,
readText,
resolveCachePaths,
writeJson,
};

View file

@ -0,0 +1,52 @@
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readJson,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const gameId = args.game;
if (!gameId) {
throw new Error("--game <gameId> is required");
}
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const paths = resolveCachePaths(cacheDir);
const historicalWrapper = readJson(paths.historical, { data: {} });
const pkg = await loadLckResults();
const liveWindowPayload = args.window ? JSON.parse(fs.readFileSync(path.resolve(args.window), "utf8")) : undefined;
const liveDetailsPayload = args.details ? JSON.parse(fs.readFileSync(path.resolve(args.details), "utf8")) : undefined;
const analysis = await pkg.getGameAnalysis(gameId, {
matchId: args.match,
number: args.number ? Number(args.number) : null,
state: args.state || undefined,
historicalDataset: historicalWrapper.data,
liveWindowPayload,
liveDetailsPayload,
});
const reportFile = path.join(paths.reports, `game-${gameId}.json`);
writeJson(reportFile, analysis);
process.stdout.write(formatOutput({
ok: true,
reportFile,
patch: analysis.patch,
turningPoints: analysis.turningPoints,
draftEdge: analysis.draft?.overallEdge || null,
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

View file

@ -0,0 +1,44 @@
#!/usr/bin/env node
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readJson,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const date = args.date;
if (!date) {
throw new Error("--date <YYYY-MM-DD> is required");
}
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const paths = resolveCachePaths(cacheDir);
const historicalWrapper = readJson(paths.historical, { data: {} });
const pkg = await loadLckResults();
const analysis = await pkg.getMatchAnalysis(date, {
team: args.team || undefined,
historicalDataset: historicalWrapper.data,
});
const reportFile = path.join(paths.reports, `match-${date}${args.team ? `-${args.team}` : ""}.json`);
writeJson(reportFile, analysis);
process.stdout.write(formatOutput({
ok: true,
reportFile,
queryDate: analysis.queryDate,
matchCount: analysis.matches.length,
teams: analysis.matches.map((match) => `${match.team1?.name} vs ${match.team2?.name}`),
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
const path = require("node:path");
const {
formatOutput,
loadLckResults,
parseArgs,
readText,
resolveCachePaths,
writeJson,
} = require("./_lib");
async function main() {
const args = parseArgs(process.argv.slice(2));
const cacheDir = path.resolve(args.cache || path.join(process.cwd(), ".openclaw-lck-cache"));
const csvPath = args.csv ? path.resolve(args.csv) : path.join(__dirname, "..", "samples", "oracle-lck-sample.csv");
const league = args.league || "LCK";
const csvText = readText(csvPath);
if (!csvText.trim()) {
throw new Error(`CSV not found or empty: ${csvPath}`);
}
const pkg = await loadLckResults();
const historical = pkg.buildHistoricalAnalytics(csvText, { league });
const paths = resolveCachePaths(cacheDir);
writeJson(paths.historical, {
source: {
type: "oracle-style-csv",
csvPath,
league,
updatedAt: new Date().toISOString(),
},
data: historical,
});
process.stdout.write(formatOutput({
ok: true,
cacheFile: paths.historical,
teamRatings: historical.teamPowerRatings.length,
matchupStats: historical.matchupStats.length,
synergyStats: historical.synergyStats.length,
patchMeta: historical.patchMeta.length,
}));
}
main().catch((error) => {
console.error(error.stack || String(error));
process.exitCode = 1;
});

164
olive-young-search/SKILL.md Normal file
View file

@ -0,0 +1,164 @@
---
name: olive-young-search
description: upstream daiso CLI를 사용해 올리브영 매장 검색, 상품 검색, 재고 확인을 조회한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Olive Young Search
## What this skill does
upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) 와 npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용해 **올리브영 매장 검색, 상품 검색, 재고 확인** 흐름을 안내한다.
이 저장소는 원본 MCP 서버 코드를 vendoring 하지 않는다. 대신 **MCP 서버를 Claude Code에 직접 설치하지 않고 CLI 형태로 먼저 확인하는 경로**를 기본값으로 둔다.
핵심 조회 경로:
- 매장 검색: `/api/oliveyoung/stores`
- 상품 검색: `/api/oliveyoung/products`
- 재고 확인: `/api/oliveyoung/inventory`
- health check: `npx --yes daiso health`
## When to use
- "명동 근처 올리브영 매장 찾아줘"
- "올리브영 선크림 어떤 거 있나 보여줘"
- "명동 근처 올리브영에서 선크림 재고 확인해줘"
- "올리브영 검색용 CLI 붙여줘"
## When not to use
- 로그인, 주문, 장바구니, 결제 자동화
- 올리브영 계정/세션이 필요한 private 기능
- upstream 서버 코드를 이 저장소 안에 복사해서 유지하려는 경우
## Prerequisites
- 인터넷 연결
- `node` 20 권장 (`hmmhmmhm/daiso-mcp` 2026-04-05 기준 `engines.node``>=20 <21`)
- `npx` 또는 `npm`
- 필요하면 `git`
Node 22에서도 로컬 smoke test는 성공했지만 `EBADENGINE` 경고가 보여서, **안정 경로는 Node 20 LTS** 로 본다.
## Preferred setup: CLI first, not direct MCP install
가장 빠른 경로는 MCP 연결부터 하지 않고 upstream CLI로 공개 endpoint를 확인하는 것이다.
```bash
npx --yes daiso health
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g daiso
export NODE_PATH="$(npm root -g)"
daiso health
```
## Fallback: clone the original repository and run the same CLI locally
public endpoint 재시도나 버전 고정이 필요하면 원본 저장소를 clone 해서 build 결과물 `dist/bin.js``node` 로 직접 실행한다.
clone checkout 안에서는 `npx daiso ...``Permission denied` 로 실패할 수 있으므로, local fallback은 아래 경로를 기본으로 둔다.
```bash
git clone https://github.com/hmmhmmhm/daiso-mcp.git
cd daiso-mcp
npm install
npm run build
node dist/bin.js health
node dist/bin.js get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --json
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
즉, 이 스킬의 기본 원칙은 **원본 `hmmhmmhm/daiso-mcp`를 설치/실행해서 쓰고, `k-skill`에는 가이드만 추가하는 것**이다.
## Required inputs
### 1. Store or area keyword first when place context is missing
- 권장 질문: `어느 지역/매장을 기준으로 볼까요? 예: 명동, 강남역, 성수`
- 재고 질문인데 지역이 없으면 먼저 지역/매장 키워드를 받는다.
### 2. Product keyword first when inventory is requested
- 권장 질문: `찾을 상품 키워드도 알려주세요. 예: 선크림, 립밤, 마스크팩`
- 상품 종류를 묻는 경우에도 키워드를 너무 넓게 받지 않는다.
## Workflow
### 1. Check server health
```bash
npx --yes daiso health
```
### 2. Resolve nearby stores
```bash
npx --yes daiso get /api/oliveyoung/stores --keyword 명동 --limit 5 --json
```
매장 후보가 여러 개면 상위 2~3개만 요약하고 다시 확인받는다.
### 3. Resolve product candidates
```bash
npx --yes daiso get /api/oliveyoung/products --keyword 선크림 --size 5 --json
```
상품 후보가 많으면 `goodsNumber`, 가격, 이미지 URL, `inStock` 여부를 함께 짧게 정리한다.
### 4. Check inventory for the chosen area/store keyword
```bash
npx --yes daiso get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
응답의 `inventory.products[].storeInventory.stores[]` 안에서 다음 값을 우선 본다.
- `stockLabel`
- `remainQuantity`
- `stockStatus`
- `storeName`
### 5. Respond conservatively
최종 응답은 아래 순서로 짧게 정리한다.
- 기준 지역/매장 키워드
- 상위 매장 후보
- 상품 후보 또는 선택 상품
- 재고 있는 매장 / 품절 / 미판매 구분
- 필요하면 `imageUrl` 참고 링크
- 공개 endpoint 특성상 재고는 실시간 100% 보장값이 아니므로 방문 직전 재확인을 권장
## Done when
- `hmmhmmhm/daiso-mcp` 원본 repo와 `daiso` CLI 사용 경로를 명시했다.
- MCP 서버를 직접 설치하는 대신 CLI first 흐름을 제시했다.
- 매장 검색 → 상품 검색 → 재고 확인 순서를 따랐다.
- `/api/oliveyoung/stores`, `/api/oliveyoung/products`, `/api/oliveyoung/inventory` 중 필요한 호출을 실제로 안내했다.
- 재고 결과를 매장별 `stockLabel` 중심으로 요약했다.
## Failure modes
- public endpoint는 upstream 내부 수집 경로(Zyte 의존) 사정으로 간헐적인 5xx/503을 줄 수 있다.
- 지역 키워드가 너무 넓으면 멀리 떨어진 동명이점 매장이 섞일 수 있다.
- 인기 상품은 검색 결과가 많아 상위 몇 개만 먼저 확인받는 편이 안전하다.
- 재고 수량은 시점 차이로 실제 방문 시 달라질 수 있다.
## Notes
- 원본 프로젝트: `https://github.com/hmmhmmhm/daiso-mcp`
- npm package: `https://www.npmjs.com/package/daiso`
- 이 저장소는 upstream 코드를 vendoring 하지 않고 skill/docs만 유지한다.

34
package-lock.json generated
View file

@ -574,6 +574,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/cheap-gas-nearby": {
"resolved": "packages/cheap-gas-nearby",
"link": true
},
"node_modules/cookie": {
"version": "1.1.1",
"license": "MIT",
@ -585,10 +589,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/coupang-product-search": {
"resolved": "packages/coupang-product-search",
"link": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -993,6 +993,10 @@
"resolved": "packages/kleague-results",
"link": true
},
"node_modules/lck-analytics": {
"resolved": "packages/lck-analytics",
"link": true
},
"node_modules/light-my-request": {
"version": "6.6.0",
"funding": [
@ -1616,28 +1620,29 @@
}
},
"packages/blue-ribbon-nearby": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/coupang-product-search": {
"packages/cheap-gas-nearby": {
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/daiso-product-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/k-lotto": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1654,13 +1659,20 @@
}
},
"packages/kakao-bar-nearby": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/kleague-results": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/lck-analytics": {
"version": "0.1.0",
"license": "MIT",
"engines": {
@ -1668,14 +1680,14 @@
}
},
"packages/toss-securities": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/used-car-price-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -9,10 +9,10 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.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 && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --dry-run",
"test": "node --test scripts/skill-docs.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace 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

@ -1,6 +1,9 @@
# blue-ribbon-nearby
Blue Ribbon Survey 공식 표면을 사용해 근처 블루리본 맛집을 찾는 Node.js 패키지입니다.
Blue Ribbon Survey 공식 표면을 사용해 위치 문자열을 공식 zone 으로 매칭하고, k-skill-proxy 를 경유해 근처 블루리본 맛집을 조회하는 Node.js 패키지입니다.
> [!NOTE]
> Blue Ribbon의 `/restaurants/map` 은 프리미엄 전용입니다. 이 패키지는 기본적으로 k-skill-proxy 를 경유해 프리미엄 세션으로 nearby 검색을 수행합니다. 직접 호출 시(`useDirectApi: true`) 프리미엄 세션 없이는 `premium_required` 에러가 발생합니다.
## 설치
@ -31,12 +34,15 @@ npm install
패키지는 먼저 `search/zone` 에서 가장 가까운 공식 zone 을 찾고, 그다음 `/restaurants/map` nearby 검색으로 블루리본 인증 맛집만 추립니다. 이때 `zone1`, `zone2`, `zone2Lat`, `zone2Lng`, `isAround=true`, `ribbon=true` 를 사용해 주변 결과만 다시 조회합니다.
nearby 검색은 기본적으로 k-skill-proxy (`/v1/blue-ribbon/nearby`) 를 경유합니다. 프록시에 `BLUE_RIBBON_SESSION_ID` 환경변수가 설정되어 있어야 합니다.
## 사용 예시
```js
const { searchNearbyByLocationQuery } = require("blue-ribbon-nearby");
async function main() {
// 기본: k-skill-proxy 경유
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
@ -52,9 +58,11 @@ main().catch((error) => {
});
```
직접 호출이 필요하면 `useDirectApi: true` 옵션을 사용하세요. 프리미엄 세션이 없으면 `premium_required` 에러가 발생합니다.
## Live smoke snapshot
2026-03-27 에 `광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출했을 때 상위 결과 예시는 아래와 같았습니다.
2026-03-27 에 `광화문`, `distanceMeters=1000`, `limit=5` 로 실제 호출했을 때 상위 결과 예시:
```json
{
@ -78,3 +86,7 @@ main().catch((error) => {
- `buildNearbySearchParams(options)`
- `searchNearbyByLocationQuery(locationQuery, options?)`
- `searchNearbyByCoordinates(options)`
기본적으로 k-skill-proxy 를 경유합니다. `useDirectApi: true` 로 직접 호출할 때 프리미엄 세션이 없으면 `premium_required` 에러가 발생합니다.
프록시 base URL 은 `options.proxyBaseUrl`, `KSKILL_PROXY_BASE_URL` 환경변수, 또는 기본값 `https://k-skill-proxy.nomadamas.org` 순으로 결정됩니다.

View file

@ -6,6 +6,8 @@ const {
parseZoneCatalogHtml
} = require("./parse");
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",
@ -22,6 +24,52 @@ const RESTAURANTS_MAP_URL = `${BASE_URL}/restaurants/map`;
const DEFAULT_DISTANCE_METERS = 1000;
const DEFAULT_RIBBON_TYPES = "RIBBON_THREE,RIBBON_TWO,RIBBON_ONE";
async function readErrorPayload(response) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("json")) {
try {
return await response.json();
} catch {
return null;
}
}
try {
return await response.text();
} catch {
return null;
}
}
function createRequestError(response, url, payload) {
if (
response.status === 403 &&
url.startsWith(RESTAURANTS_MAP_URL) &&
payload &&
payload.error === "PREMIUM_REQUIRED"
) {
const error = new Error(
"Blue Ribbon nearby results are currently premium-gated by bluer.co.kr. Zone matching still works, but live nearby restaurant data now requires official premium access.",
);
error.code = "premium_required";
error.statusCode = response.status;
error.upstreamError = payload.error;
error.upstreamUrl = url;
return error;
}
const error = new Error(`Blue Ribbon request failed with ${response.status} for ${url}`);
error.statusCode = response.status;
error.upstreamUrl = url;
if (payload && typeof payload === "object" && typeof payload.error === "string") {
error.upstreamError = payload.error;
}
return error;
}
async function request(url, options = {}, responseType = "text") {
const fetchImpl = options.fetchImpl || global.fetch;
@ -39,7 +87,7 @@ async function request(url, options = {}, responseType = "text") {
});
if (!response.ok) {
throw new Error(`Blue Ribbon request failed with ${response.status} for ${url}`);
throw createRequestError(response, url, await readErrorPayload(response));
}
return responseType === "json" ? response.json() : response.text();
@ -53,6 +101,26 @@ async function fetchJson(url, options = {}) {
return request(url, options, "json");
}
function resolveProxyBaseUrl(options = {}) {
return options.proxyBaseUrl || process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL;
}
function useDirectApi(options = {}) {
return options.useDirectApi === true;
}
async function fetchNearbyViaProxy(latitude, longitude, distanceMeters, limit, options = {}) {
const base = resolveProxyBaseUrl(options);
const url = new URL(`${base}/v1/blue-ribbon/nearby`);
url.searchParams.set("latitude", String(latitude));
url.searchParams.set("longitude", String(longitude));
url.searchParams.set("distanceMeters", String(distanceMeters));
url.searchParams.set("limit", String(limit));
const payload = await fetchJson(url.toString(), options);
return payload;
}
function assertDistanceMeters(distanceMeters) {
if (!Number.isFinite(distanceMeters) || distanceMeters <= 0) {
throw new Error("distanceMeters must be a positive number.");
@ -151,6 +219,21 @@ async function searchNearbyByCoordinates(options) {
throw new Error("latitude and longitude must be finite numbers.");
}
if (!useDirectApi(options)) {
const proxyPayload = await fetchNearbyViaProxy(latitude, longitude, distanceMeters, limit, options);
const origin = { latitude, longitude };
const items = normalizeNearbyResults(proxyPayload, origin, limit);
return {
anchor: { latitude, longitude },
items,
meta: {
total: Number(proxyPayload.total || items.length),
capped: Boolean(proxyPayload.capped)
}
};
}
const params = buildNearbySearchParams({
latitude,
longitude,
@ -213,20 +296,32 @@ async function searchNearbyByLocationQuery(locationQuery, options = {}) {
const anchor = matches[0].zone;
const distanceMeters = Number(options.distanceMeters ?? DEFAULT_DISTANCE_METERS);
const limit = options.limit ?? 10;
const origin = { latitude: anchor.latitude, longitude: anchor.longitude };
if (!useDirectApi(options)) {
const proxyPayload = await fetchNearbyViaProxy(
anchor.latitude, anchor.longitude, distanceMeters, limit, options
);
const items = normalizeNearbyResults(proxyPayload, origin, limit);
return {
anchor,
candidates: matches,
items,
meta: {
total: Number(proxyPayload.total || items.length),
capped: Boolean(proxyPayload.capped)
}
};
}
const params = buildNearbySearchParams({
zone: anchor,
distanceMeters,
sort: options.sort
});
const payload = await fetchNearbyMap(params, options);
const items = normalizeNearbyResults(
payload,
{
latitude: anchor.latitude,
longitude: anchor.longitude
},
limit,
);
const items = normalizeNearbyResults(payload, origin, limit);
return {
anchor,

View file

@ -8,6 +8,7 @@ const {
findZoneMatches,
normalizeNearbyItem,
parseZoneCatalogHtml,
searchNearbyByCoordinates,
searchNearbyByLocationQuery
} = require("../src/index");
@ -20,6 +21,10 @@ const landmarkZoneHtml = `
<a href="/search?query=&zone1=${encodeURIComponent("서울 강남")}&zone2=${encodeURIComponent("삼성동/대치동")}&zone2Lat=37.511310&zone2Lng=127.059330">삼성동/대치동</a>
<a href="/search?query=&zone1=${encodeURIComponent("서울 강북")}&zone2=${encodeURIComponent("남대문/서울역/후암동")}&zone2Lat=37.555000&zone2Lng=126.972000">남대문/서울역/후암동</a>
`;
const gwanghwamunCoordinates = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseZoneCatalogHtml extracts official zone anchors and coordinates", () => {
const zones = parseZoneCatalogHtml(zoneHtml);
@ -68,10 +73,7 @@ test("buildNearbySearchParams encodes the official nearby ribbon query for a mat
});
test("normalizeNearbyItem exposes the public restaurant summary with computed distance", () => {
const item = normalizeNearbyItem(mapPayload.items[0], {
latitude: 37.57371315593711,
longitude: 126.97833785777944
});
const item = normalizeNearbyItem(mapPayload.items[0], gwanghwamunCoordinates);
assert.equal(item.id, 29209);
assert.equal(item.name, "유유안");
@ -87,20 +89,21 @@ test("searchNearbyByLocationQuery resolves documented landmark aliases like 코
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(true, landmarkZoneHtml, "text/html");
return makeResponse(200, landmarkZoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(true, mapPayload, "application/json");
return makeResponse(200, mapPayload, "application/json");
}
return makeResponse(false, "not found", "text/plain");
return makeResponse(404, "not found", "text/plain");
};
try {
const result = await searchNearbyByLocationQuery("코엑스", {
distanceMeters: 1000,
limit: 5
limit: 5,
useDirectApi: true
});
assert.equal(result.anchor.zone2, "삼성동/대치동");
@ -117,20 +120,21 @@ test("searchNearbyByLocationQuery resolves a zone match, fetches the official ne
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(true, zoneHtml, "text/html");
return makeResponse(200, zoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(true, mapPayload, "application/json");
return makeResponse(200, mapPayload, "application/json");
}
return makeResponse(false, "not found", "text/plain");
return makeResponse(404, "not found", "text/plain");
};
try {
const result = await searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5
limit: 5,
useDirectApi: true
});
assert.equal(result.anchor.zone2, "광화문/종로2가");
@ -149,9 +153,113 @@ test("searchNearbyByLocationQuery resolves a zone match, fetches the official ne
}
});
function makeResponse(ok, body, contentType) {
test("searchNearbyByLocationQuery surfaces PREMIUM_REQUIRED with a stable domain error", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/search/zone")) {
return makeResponse(200, zoneHtml, "text/html");
}
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "PREMIUM_REQUIRED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByLocationQuery("광화문", {
distanceMeters: 1000,
limit: 5,
useDirectApi: true
}),
assertPremiumRequiredError
);
} finally {
global.fetch = originalFetch;
}
});
test("searchNearbyByCoordinates surfaces PREMIUM_REQUIRED with the same domain error", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "PREMIUM_REQUIRED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByCoordinates({
...gwanghwamunCoordinates,
distanceMeters: 1000,
limit: 5,
useDirectApi: true
}),
assertPremiumRequiredError
);
} finally {
global.fetch = originalFetch;
}
});
test("searchNearbyByCoordinates keeps non-premium upstream failures generic", async () => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
if (String(url).includes("/restaurants/map")) {
return makeResponse(403, { error: "ACCESS_DENIED" }, "application/json");
}
return makeResponse(404, "not found", "text/plain");
};
try {
await assert.rejects(
() =>
searchNearbyByCoordinates({
...gwanghwamunCoordinates,
distanceMeters: 1000,
limit: 5,
useDirectApi: true
}),
assertGenericRequestError
);
} finally {
global.fetch = originalFetch;
}
});
function assertPremiumRequiredError(error) {
return (
error.statusCode === 403 &&
error.code === "premium_required" &&
error.upstreamError === "PREMIUM_REQUIRED" &&
error.upstreamUrl.includes("/restaurants/map") &&
/premium/i.test(error.message)
);
}
function assertGenericRequestError(error) {
return (
error.statusCode === 403 &&
error.code === undefined &&
error.upstreamError === "ACCESS_DENIED" &&
error.upstreamUrl.includes("/restaurants/map") &&
/request failed/i.test(error.message)
);
}
function makeResponse(status, body, contentType) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: ok ? 200 : 500,
status,
headers: {
"content-type": contentType
}

View file

@ -0,0 +1,68 @@
# cheap-gas-nearby
한국석유공사 오피넷(Opinet) 공식 API를 사용해 근처의 가장 싼 주유소를 찾는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install cheap-gas-nearby
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 유저 위치는 자동으로 추적하지 않습니다.
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
- 가격 데이터는 공식 Opinet Open API를 우선 사용합니다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡은 뒤, Opinet `aroundAll.do` 검색으로 연결합니다.
- 공식 API key (`OPINET_API_KEY`) 또는 `apiKey` 옵션이 필요합니다.
## 공식 표면
- 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
- Kakao Map anchor 검색: `https://m.map.kakao.com/actions/searchView`
## 사용 예시
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: process.env.OPINET_API_KEY,
radius: 1000,
productCode: "B027",
limit: 3
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 공개 API
- `parseSearchResultsHtml(html)`
- `selectAnchorCandidate(query, items)`
- `normalizeAnchorPanel(panel, searchItem)`
- `wgs84ToKatec(latitude, longitude)`
- `buildAroundSearchParams(options)`
- `parseAroundResponse(payload)`
- `normalizeDetailItem(payload)`
- `searchCheapGasStationsByCoordinates(options)`
- `searchCheapGasStationsByLocationQuery(locationQuery, options)`

View file

@ -0,0 +1,32 @@
{
"name": "cheap-gas-nearby",
"version": "0.1.0",
"description": "Official Opinet based nearby cheapest gas station 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",
"opinet",
"gas-station",
"fuel-price"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,325 @@
const {
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
rankAnchorCandidates,
selectAnchorCandidate,
sortStationsByPriceAndDistance,
wgs84ToKatec
} = 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 OPINET_BASE_URL = "https://www.opinet.co.kr/api";
const AROUND_ALL_URL = `${OPINET_BASE_URL}/aroundAll.do`;
const DETAIL_BY_ID_URL = `${OPINET_BASE_URL}/detailById.do`;
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/",
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site"
};
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 = "text") {
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) {
throw new Error(`Request failed with ${response.status} for ${url}`);
}
return responseType === "json" ? response.json() : response.text();
}
function resolveProxyBaseUrl(options = {}) {
return options.proxyBaseUrl || process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL;
}
function resolveApiKey(options = {}) {
const apiKey = options.apiKey || options.certKey || process.env.OPINET_API_KEY;
if (!apiKey && options.useDirectApi) {
throw new Error("OPINET_API_KEY or options.apiKey is required for official Opinet lookups.");
}
return apiKey;
}
function useDirectApi(options = {}) {
return options.useDirectApi || !!(options.apiKey || options.certKey);
}
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])
};
}
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");
}
async function fetchOpinetJson(url, options = {}) {
return request(url, { ...options, headerSet: DEFAULT_JSON_HEADERS }, "json");
}
function hasFiniteAnchorCoordinates(anchor) {
return Number.isFinite(anchor?.latitude) && Number.isFinite(anchor?.longitude);
}
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) {
try {
const anchorPanel = await fetchPlacePanel(candidate.id, options);
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (hasFiniteAnchorCoordinates(anchor)) {
return {
anchor,
anchorCandidates
};
}
} catch (error) {
if (!/404/.test(String(error.message || error))) {
throw error;
}
}
}
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
}
function normalizeCountOption(value, fallback, label, minimum = 0) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${label} must be a finite number.`);
}
return Math.max(minimum, parsed);
}
async function fetchAroundStations({ x, y, radius, productCode, apiKey, sort = 1 }, options = {}) {
if (apiKey || useDirectApi(options)) {
const url = new URL(AROUND_ALL_URL);
const params = buildAroundSearchParams({ x, y, radius, productCode, sort });
for (const [key, value] of Object.entries({ ...params, certkey: apiKey })) {
url.searchParams.set(key, value);
}
return parseAroundResponse(await fetchOpinetJson(url.toString(), options));
}
const base = resolveProxyBaseUrl(options);
const url = new URL(`${base}/v1/opinet/around`);
url.searchParams.set("x", x);
url.searchParams.set("y", y);
url.searchParams.set("radius", radius);
url.searchParams.set("prodcd", productCode);
url.searchParams.set("sort", sort);
return parseAroundResponse(await fetchOpinetJson(url.toString(), options));
}
async function fetchDetailById(id, apiKey, options = {}) {
if (apiKey || useDirectApi(options)) {
const url = new URL(DETAIL_BY_ID_URL);
url.searchParams.set("out", "json");
url.searchParams.set("id", id);
url.searchParams.set("certkey", apiKey);
return normalizeDetailItem(await fetchOpinetJson(url.toString(), options));
}
const base = resolveProxyBaseUrl(options);
const url = new URL(`${base}/v1/opinet/detail`);
url.searchParams.set("id", id);
return normalizeDetailItem(await fetchOpinetJson(url.toString(), options));
}
function mergeStationDetail(aroundItem, detailItem) {
if (!detailItem) {
return aroundItem;
}
return {
...aroundItem,
...detailItem,
price: aroundItem.price,
distanceMeters: aroundItem.distanceMeters,
name: detailItem.name || aroundItem.name,
brandCode: detailItem.brandCode || aroundItem.brandCode,
brandName: detailItem.brandName || aroundItem.brandName
};
}
async function searchCheapGasStationsByCoordinates(options = {}) {
const latitude = Number(options.latitude);
const longitude = Number(options.longitude);
const radius = Number(options.radius ?? 1000);
const productCode = options.productCode || "B027";
const limit = normalizeCountOption(options.limit, 5, "limit", 1);
const detailLimit = normalizeCountOption(options.detailLimit, limit, "detailLimit", 0);
const apiKey = resolveApiKey(options);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const katec = wgs84ToKatec(latitude, longitude);
const aroundItems = await fetchAroundStations(
{
x: katec.x,
y: katec.y,
radius,
productCode,
apiKey: apiKey || undefined,
sort: options.sort ?? 1
},
options,
);
const rankedItems = sortStationsByPriceAndDistance(aroundItems);
const detailTargets = rankedItems.slice(0, detailLimit);
const detailEntries = await Promise.all(
detailTargets.map(async (item) => {
try {
return [item.id, await fetchDetailById(item.id, apiKey || undefined, options)];
} catch (error) {
return [item.id, { error: String(error.message || error) }];
}
}),
);
const detailMap = new Map(detailEntries);
return {
anchor: {
latitude,
longitude,
katecX: katec.x,
katecY: katec.y
},
items: rankedItems.slice(0, limit).map((item) => mergeStationDetail(item, detailMap.get(item.id))),
meta: {
productCode,
radius,
total: rankedItems.length
}
};
}
async function searchCheapGasStationsByLocationQuery(locationQuery, options = {}) {
const query = String(locationQuery || "").trim();
if (!query) {
throw new Error("locationQuery is required.");
}
const coordinateQuery = parseCoordinateQuery(query);
if (coordinateQuery) {
return searchCheapGasStationsByCoordinates({
...options,
...coordinateQuery
});
}
const { anchor, anchorCandidates } = await resolveAnchor(query, options);
const result = await searchCheapGasStationsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude
});
return {
...result,
anchor,
anchorCandidates,
meta: {
...result.meta,
resolvedQuery: query
}
};
}
module.exports = {
AROUND_ALL_URL,
DEFAULT_PROXY_BASE_URL,
DETAIL_BY_ID_URL,
PLACE_PANEL_URL_BASE,
SEARCH_VIEW_URL,
buildAroundSearchParams,
fetchDetailById,
fetchPlacePanel,
fetchSearchResults,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
searchCheapGasStationsByCoordinates,
searchCheapGasStationsByLocationQuery,
selectAnchorCandidate,
wgs84ToKatec
};

View file

@ -0,0 +1,447 @@
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 GAS_STATION_PATTERN = /(주유소|충전소|셀프주유소|가스충전소)/u;
const WGS84_A = 6378137.0;
const WGS84_F = 1 / 298.257223563;
const BESSEL_A = 6377397.155;
const BESSEL_F = 1 / 299.1528128;
const KATEC_LAT0 = degreesToRadians(38.0);
const KATEC_LON0 = degreesToRadians(128.0);
const KATEC_FALSE_EASTING = 400000.0;
const KATEC_FALSE_NORTHING = 600000.0;
const KATEC_SCALE = 0.9999;
const WGS84_TO_BESSEL = [146.43, -507.89, -681.46];
const BRAND_NAMES = {
ETC: "자가상표",
E1G: "E1",
GSC: "GS칼텍스",
HDO: "현대오일뱅크",
NHO: "농협알뜰",
RTE: "자영알뜰",
RTX: "고속도로알뜰",
SKE: "SK에너지",
SKG: "SK가스",
SOL: "S-OIL"
};
const PRODUCT_CODE_TO_KEY = {
B027: "gasoline",
B034: "premiumGasoline",
C004: "kerosene",
D047: "diesel",
K015: "lpg"
};
function degreesToRadians(value) {
return (value * Math.PI) / 180;
}
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 toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(String(value).replace(/,/g, ""));
return Number.isFinite(parsed) ? parsed : null;
}
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 (GAS_STATION_PATTERN.test(`${item.name} ${item.category}`)) {
score -= 200;
}
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 selectAnchorCandidate(query, items) {
const ranked = rankAnchorCandidates(query, items);
if (ranked.length === 0) {
throw new Error("No Kakao Map place candidate matched that location query.");
}
return ranked[0];
}
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 meridionalArc(phi, semiMajorAxis, eccentricitySquared) {
const e2 = eccentricitySquared;
return semiMajorAxis * (
(1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * phi -
((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * Math.sin(2 * phi) +
((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * phi) -
((35 * e2 ** 3) / 3072) * Math.sin(6 * phi)
);
}
function wgs84ToBessel(lat, lon) {
const [dx, dy, dz] = WGS84_TO_BESSEL;
const sourceEccentricitySquared = 2 * WGS84_F - WGS84_F ** 2;
const targetEccentricitySquared = 2 * BESSEL_F - BESSEL_F ** 2;
const latitudeRadians = degreesToRadians(lat);
const longitudeRadians = degreesToRadians(lon);
const sinLatitude = Math.sin(latitudeRadians);
const cosLatitude = Math.cos(latitudeRadians);
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - sourceEccentricitySquared * sinLatitude ** 2);
const x = primeVerticalRadius * cosLatitude * Math.cos(longitudeRadians) + dx;
const y = primeVerticalRadius * cosLatitude * Math.sin(longitudeRadians) + dy;
const z = primeVerticalRadius * (1 - sourceEccentricitySquared) * sinLatitude + dz;
const besselLongitude = Math.atan2(y, x);
const horizontal = Math.sqrt(x ** 2 + y ** 2);
let besselLatitude = Math.atan2(z, horizontal * (1 - targetEccentricitySquared));
for (let index = 0; index < 8; index += 1) {
const sinBesselLatitude = Math.sin(besselLatitude);
const besselRadius = BESSEL_A / Math.sqrt(1 - targetEccentricitySquared * sinBesselLatitude ** 2);
const nextLatitude = Math.atan2(z + targetEccentricitySquared * besselRadius * sinBesselLatitude, horizontal);
if (Math.abs(nextLatitude - besselLatitude) < 1e-14) {
besselLatitude = nextLatitude;
break;
}
besselLatitude = nextLatitude;
}
return {
latitudeRadians: besselLatitude,
longitudeRadians: besselLongitude
};
}
function wgs84ToKatec(latitude, longitude) {
const { latitudeRadians, longitudeRadians } = wgs84ToBessel(latitude, longitude);
const besselEccentricitySquared = 2 * BESSEL_F - BESSEL_F ** 2;
const secondEccentricitySquared = besselEccentricitySquared / (1 - besselEccentricitySquared);
const sinLatitude = Math.sin(latitudeRadians);
const cosLatitude = Math.cos(latitudeRadians);
const tanLatitude = Math.tan(latitudeRadians);
const primeVerticalRadius = BESSEL_A / Math.sqrt(1 - besselEccentricitySquared * sinLatitude ** 2);
const tanSquared = tanLatitude ** 2;
const curvature = secondEccentricitySquared * cosLatitude ** 2;
const A = (longitudeRadians - KATEC_LON0) * cosLatitude;
const meridional = meridionalArc(latitudeRadians, BESSEL_A, besselEccentricitySquared);
const meridionalOrigin = meridionalArc(KATEC_LAT0, BESSEL_A, besselEccentricitySquared);
const x =
KATEC_FALSE_EASTING +
KATEC_SCALE *
primeVerticalRadius *
(A + ((1 - tanSquared + curvature) * A ** 3) / 6 +
((5 - 18 * tanSquared + tanSquared ** 2 + 72 * curvature - 58 * secondEccentricitySquared) * A ** 5) /
120);
const y =
KATEC_FALSE_NORTHING +
KATEC_SCALE *
(meridional - meridionalOrigin +
primeVerticalRadius *
tanLatitude *
(A ** 2 / 2 +
((5 - tanSquared + 9 * curvature + 4 * curvature ** 2) * A ** 4) / 24 +
((61 - 58 * tanSquared + tanSquared ** 2 + 600 * curvature - 330 * secondEccentricitySquared) * A ** 6) /
720));
return {
x,
y
};
}
function formatCoordinate(value) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
throw new Error("Coordinate values must be finite numbers.");
}
return numericValue.toFixed(4);
}
function buildAroundSearchParams({ x, y, radius, productCode, sort = 1 } = {}) {
if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) {
throw new Error("x and y are required KATEC coordinates.");
}
const normalizedRadius = Number(radius ?? 1000);
if (!Number.isFinite(normalizedRadius) || normalizedRadius <= 0 || normalizedRadius > 5000) {
throw new Error("radius must be a positive number up to 5000 meters.");
}
return {
out: "json",
x: formatCoordinate(x),
y: formatCoordinate(y),
radius: String(Math.round(normalizedRadius)),
prodcd: productCode || "B027",
sort: String(sort)
};
}
function extractOilEntries(payload) {
const oil = payload?.RESULT?.OIL;
if (Array.isArray(oil)) {
return oil;
}
if (oil && typeof oil === "object") {
return [oil];
}
return [];
}
function normalizeAroundItem(item) {
return {
id: String(item.UNI_ID || item.uni_id || ""),
brandCode: String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || ""),
brandName:
BRAND_NAMES[String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || "")] ||
String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || ""),
name: item.OS_NM || item.os_nm || "",
price: toNumber(item.PRICE ?? item.price),
distanceMeters: toNumber(item.DISTANCE ?? item.distance),
katecX: toNumber(item.GIS_X_COOR ?? item.gis_x_coor),
katecY: toNumber(item.GIS_Y_COOR ?? item.gis_y_coor)
};
}
function parseAroundResponse(payload) {
return extractOilEntries(payload)
.map((item) => normalizeAroundItem(item))
.filter((item) => item.id && Number.isFinite(item.price));
}
function normalizeProductPrices(priceEntries) {
const priceList = Array.isArray(priceEntries) ? priceEntries : priceEntries ? [priceEntries] : [];
const prices = {};
const raw = {};
for (const entry of priceList) {
const productCode = String(entry.PRODCD || entry.prodcd || "");
const key = PRODUCT_CODE_TO_KEY[productCode] || productCode;
const price = toNumber(entry.PRICE ?? entry.price);
raw[productCode] = {
price,
tradeDate: String(entry.TRADE_DT || entry.trade_dt || "") || null,
tradeTime: String(entry.TRADE_TM || entry.trade_tm || "") || null
};
if (key) {
prices[key] = price;
}
}
return {
prices,
raw
};
}
function normalizeDetailItem(payload) {
const [item] = extractOilEntries(payload);
if (!item) {
throw new Error("Opinet detail payload did not include an OIL record.");
}
const priceSummary = normalizeProductPrices(item.OIL_PRICE || item.oil_price);
const brandCode = String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || "");
return {
id: String(item.UNI_ID || item.uni_id || ""),
brandCode,
brandName: BRAND_NAMES[brandCode] || brandCode,
name: item.OS_NM || item.os_nm || "",
lotAddress: item.VAN_ADR || item.van_adr || null,
roadAddress: item.NEW_ADR || item.new_adr || null,
phone: item.TEL || item.tel || null,
sigunCode: item.SIGUNCD || item.siguncd || null,
lpgYn: item.LPG_YN || item.lpg_yn || null,
isSelf: String(item.SELF_YN || item.self_yn || "N") === "Y",
hasMaintenance: String(item.MAINT_YN || item.maint_yn || "N") === "Y",
hasCarWash: String(item.CAR_WASH_YN || item.car_wash_yn || "N") === "Y",
hasConvenienceStore: String(item.CVS_YN || item.cvs_yn || "N") === "Y",
kpetroCertified: String(item.KPETRO_YN || item.kpetro_yn || "N") === "Y",
katecX: toNumber(item.GIS_X_COOR ?? item.gis_x_coor),
katecY: toNumber(item.GIS_Y_COOR ?? item.gis_y_coor),
prices: priceSummary.prices,
rawPrices: priceSummary.raw
};
}
function sortStationsByPriceAndDistance(items) {
return [...items].sort((left, right) => {
if ((left.price ?? Number.POSITIVE_INFINITY) !== (right.price ?? Number.POSITIVE_INFINITY)) {
return (left.price ?? Number.POSITIVE_INFINITY) - (right.price ?? Number.POSITIVE_INFINITY);
}
if ((left.distanceMeters ?? Number.POSITIVE_INFINITY) !== (right.distanceMeters ?? Number.POSITIVE_INFINITY)) {
return (left.distanceMeters ?? Number.POSITIVE_INFINITY) - (right.distanceMeters ?? Number.POSITIVE_INFINITY);
}
return String(left.name || "").localeCompare(String(right.name || ""), "ko");
});
}
module.exports = {
BRAND_NAMES,
PRODUCT_CODE_TO_KEY,
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeAroundItem,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
rankAnchorCandidates,
selectAnchorCandidate,
sortStationsByPriceAndDistance,
wgs84ToKatec
};

View file

@ -0,0 +1,21 @@
{
"summary": {
"confirm_id": "1001",
"name": "서울역",
"category": {
"name1": "교통,수송",
"name2": "기차역",
"name3": "기차역"
},
"point": {
"lon": 126.97068,
"lat": 37.55472
},
"address": {
"disp": "서울 용산구 동자동"
},
"phone_numbers": [
{ "tel": "1544-7788" }
]
}
}

View file

@ -0,0 +1,22 @@
<ul id="placeList" class="list_result ">
<li class="search_item base" data-id="1001" data-cid="1001" data-title="서울역" data-phone="1544-7788">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">서울역</strong><span class="txt_ginfo ">기차역</span>
</span>
<span class="txt_g">서울 용산구 동자동</span>
</span>
</a>
</li>
<li class="search_item base" data-id="1002" data-cid="1002" data-title="서울로7017" data-phone="">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">서울로7017</strong><span class="txt_ginfo ">테마거리</span>
</span>
<span class="txt_g">서울 중구 만리재로</span>
</span>
</a>
</li>
</ul>

View file

@ -0,0 +1,33 @@
{
"RESULT": {
"OIL": [
{
"UNI_ID": "A1000001",
"POLL_DIV_CO": "SKE",
"OS_NM": "서울역셀프주유소",
"PRICE": "1635",
"DISTANCE": "112.4",
"GIS_X_COOR": "309240.0000",
"GIS_Y_COOR": "550790.0000"
},
{
"UNI_ID": "A1000002",
"POLL_DIV_CO": "RTE",
"OS_NM": "만리알뜰주유소",
"PRICE": "1649",
"DISTANCE": "315.0",
"GIS_X_COOR": "309010.2000",
"GIS_Y_COOR": "550940.1000"
},
{
"UNI_ID": "A1000003",
"POLL_DIV_CO": "SOL",
"OS_NM": "서울로주유소",
"PRICE": "1649",
"DISTANCE": "220.0",
"GIS_X_COOR": "309500.3000",
"GIS_Y_COOR": "550820.5000"
}
]
}
}

View file

@ -0,0 +1,25 @@
{
"RESULT": {
"OIL": {
"UNI_ID": "A1000001",
"POLL_DIV_CO": "SKE",
"OS_NM": "서울역셀프주유소",
"VAN_ADR": "서울 용산구 동자동 43-205",
"NEW_ADR": "서울 용산구 한강대로 405",
"TEL": "02-1111-2222",
"SIGUNCD": "0101",
"LPG_YN": "N",
"MAINT_YN": "Y",
"CAR_WASH_YN": "Y",
"CVS_YN": "N",
"KPETRO_YN": "Y",
"SELF_YN": "Y",
"GIS_X_COOR": "309240.0000",
"GIS_Y_COOR": "550790.0000",
"OIL_PRICE": [
{ "PRODCD": "B027", "PRICE": "1635", "TRADE_DT": "20260405", "TRADE_TM": "091501" },
{ "PRODCD": "D047", "PRICE": "1529", "TRADE_DT": "20260405", "TRADE_TM": "091455" }
]
}
}
}

View file

@ -0,0 +1,25 @@
{
"RESULT": {
"OIL": {
"UNI_ID": "A1000003",
"POLL_DIV_CO": "SOL",
"OS_NM": "서울로주유소",
"VAN_ADR": "서울 중구 봉래동2가 122",
"NEW_ADR": "서울 중구 통일로 10",
"TEL": "02-3333-4444",
"SIGUNCD": "0102",
"LPG_YN": "N",
"MAINT_YN": "N",
"CAR_WASH_YN": "Y",
"CVS_YN": "Y",
"KPETRO_YN": "N",
"SELF_YN": "N",
"GIS_X_COOR": "309500.3000",
"GIS_Y_COOR": "550820.5000",
"OIL_PRICE": [
{ "PRODCD": "B027", "PRICE": "1649", "TRADE_DT": "20260405", "TRADE_TM": "091420" },
{ "PRODCD": "D047", "PRICE": "1539", "TRADE_DT": "20260405", "TRADE_TM": "091410" }
]
}
}
}

View file

@ -0,0 +1,376 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
searchCheapGasStationsByLocationQuery,
selectAnchorCandidate,
wgs84ToKatec
} = 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 aroundResponse = JSON.parse(fs.readFileSync(path.join(fixturesDir, "around-response.json"), "utf8"));
const detailA1000001 = JSON.parse(fs.readFileSync(path.join(fixturesDir, "detail-a1000001.json"), "utf8"));
const detailA1000003 = JSON.parse(fs.readFileSync(path.join(fixturesDir, "detail-a1000003.json"), "utf8"));
test("parseSearchResultsHtml extracts Kakao search cards for anchor resolution", () => {
const items = parseSearchResultsHtml(anchorSearchHtml);
assert.equal(items.length, 2);
assert.deepEqual(items[0], {
id: "1001",
name: "서울역",
category: "기차역",
address: "서울 용산구 동자동",
phone: "1544-7788"
});
});
test("selectAnchorCandidate prefers the obvious station/landmark match", () => {
const anchor = selectAnchorCandidate("서울역", parseSearchResultsHtml(anchorSearchHtml));
assert.equal(anchor.id, "1001");
assert.equal(anchor.name, "서울역");
});
test("normalizeAnchorPanel keeps source URL and WGS84 coordinates", () => {
const item = normalizeAnchorPanel(anchorPanel, { id: "1001", name: "서울역", category: "기차역" });
assert.equal(item.id, "1001");
assert.equal(item.latitude, 37.55472);
assert.equal(item.longitude, 126.97068);
assert.equal(item.sourceUrl, "https://place.map.kakao.com/1001");
});
test("normalizeAnchorPanel leaves missing Kakao coordinates unusable instead of coercing them to zero", () => {
const item = normalizeAnchorPanel(
{
...anchorPanel,
summary: {
...anchorPanel.summary,
point: {
lon: null,
lat: null
}
}
},
{ id: "1001", name: "서울역", category: "기차역" },
);
assert.equal(item.latitude, null);
assert.equal(item.longitude, null);
});
test("wgs84ToKatec converts WGS84 coordinates into the KATEC values Opinet expects", () => {
const { x, y } = wgs84ToKatec(37.55472, 126.97068);
assert.ok(Math.abs(x - 309252.2237) < 1);
assert.ok(Math.abs(y - 550779.9944) < 1);
});
test("buildAroundSearchParams encodes the official Opinet nearby search contract", () => {
const params = buildAroundSearchParams({
x: 309252.2237,
y: 550779.9944,
radius: 1000,
productCode: "B027",
sort: 1
});
assert.equal(params.out, "json");
assert.equal(params.x, "309252.2237");
assert.equal(params.y, "550779.9944");
assert.equal(params.radius, "1000");
assert.equal(params.prodcd, "B027");
assert.equal(params.sort, "1");
});
test("parseAroundResponse normalizes nearby Opinet stations and keeps cheapest ordering", () => {
const items = parseAroundResponse(aroundResponse);
assert.equal(items.length, 3);
assert.deepEqual(
items.map((item) => [item.id, item.price, item.distanceMeters]),
[
["A1000001", 1635, 112.4],
["A1000002", 1649, 315],
["A1000003", 1649, 220]
]
);
});
test("normalizeDetailItem enriches a station with address, services, and product prices", () => {
const item = normalizeDetailItem(detailA1000001);
assert.equal(item.id, "A1000001");
assert.equal(item.name, "서울역셀프주유소");
assert.equal(item.brandCode, "SKE");
assert.equal(item.roadAddress, "서울 용산구 한강대로 405");
assert.equal(item.phone, "02-1111-2222");
assert.equal(item.isSelf, true);
assert.equal(item.hasCarWash, true);
assert.equal(item.hasMaintenance, true);
assert.equal(item.kpetroCertified, true);
assert.equal(item.prices.gasoline, 1635);
assert.equal(item.prices.diesel, 1529);
});
test("searchCheapGasStationsByLocationQuery resolves the anchor, queries Opinet, enriches details, and sorts by price then distance", 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=%EC%84%9C%EC%9A%B8%EC%97%AD")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved.startsWith("https://www.opinet.co.kr/api/aroundAll.do?")) {
return makeResponse(aroundResponse, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000001")) {
return makeResponse(detailA1000001, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000003")) {
return makeResponse(detailA1000003, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: "test-opinet-key",
radius: 1000,
limit: 2,
detailLimit: 2,
fetchImpl
});
assert.equal(result.anchor.name, "서울역");
assert.equal(result.anchor.sourceUrl, "https://place.map.kakao.com/1001");
assert.deepEqual(
result.items.map((item) => [item.id, item.price, item.distanceMeters, item.roadAddress]),
[
["A1000001", 1635, 112.4, "서울 용산구 한강대로 405"],
["A1000003", 1649, 220, "서울 중구 통일로 10"]
]
);
assert.equal(result.meta.productCode, "B027");
assert.equal(result.meta.radius, 1000);
assert.ok(calls.some((url) => url.includes("aroundAll.do")));
assert.ok(calls.some((url) => url.includes("detailById.do") && url.includes("A1000001")));
});
test("searchCheapGasStationsByLocationQuery falls back to the next ranked Kakao anchor candidate when the first panel has no coordinates", 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=%EC%84%9C%EC%9A%B8%EC%97%AD")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(
{
...anchorPanel,
summary: {
...anchorPanel.summary,
point: {}
}
},
"application/json",
);
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1002") {
return makeResponse(
{
...anchorPanel,
summary: {
...anchorPanel.summary,
confirm_id: "1002",
name: "서울역 1호선",
point: {
lon: 126.97253,
lat: 37.55513
},
address: {
disp: "서울 중구 봉래동2가"
},
phone_numbers: [
{ tel: "02-0000-0000" }
]
}
},
"application/json",
);
}
if (resolved.startsWith("https://www.opinet.co.kr/api/aroundAll.do?")) {
return makeResponse(aroundResponse, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000001")) {
return makeResponse(detailA1000001, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: "test-opinet-key",
limit: 1,
detailLimit: 1,
fetchImpl
});
assert.equal(result.anchor.id, "1002");
assert.equal(result.anchor.name, "서울역 1호선");
assert.equal(result.anchor.latitude, 37.55513);
assert.equal(result.anchor.longitude, 126.97253);
assert.deepEqual(
calls.filter((url) => url.startsWith("https://place-api.map.kakao.com/places/panel3/")),
[
"https://place-api.map.kakao.com/places/panel3/1001",
"https://place-api.map.kakao.com/places/panel3/1002"
]
);
});
test("searchCheapGasStationsByLocationQuery keeps score-ranked Kakao fallback order after the best panel fails", async () => {
const gangnamSearchHtml = `
<ul>
<li class="search_item base" data-id="2001" data-title="강남대로">
<strong class="tit_g">강남대로</strong>
<span class="txt_ginfo">거리</span>
<span class="txt_g">서울 강남구</span>
</li>
<li class="search_item base" data-id="2002" data-title="강남역">
<strong class="tit_g">강남역</strong>
<span class="txt_ginfo">지하철역</span>
<span class="txt_g">서울 강남구 역삼동</span>
</li>
<li class="search_item base" data-id="2003" data-title="강남역 11번출구">
<strong class="tit_g">강남역 11번출구</strong>
<span class="txt_ginfo">지하철역</span>
<span class="txt_g">서울 강남구 역삼동</span>
</li>
</ul>
`;
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%B0%95%EB%82%A8%EC%97%AD")) {
return makeResponse(gangnamSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/2002") {
return new Response(JSON.stringify({ message: "not found" }), {
status: 404,
headers: {
"content-type": "application/json"
}
});
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/2003") {
return makeResponse(
{
summary: {
confirm_id: "2003",
name: "강남역 11번출구",
category: {
name3: "지하철역"
},
address: {
disp: "서울 강남구 역삼동"
},
point: {
lon: 127.028,
lat: 37.498
}
}
},
"application/json",
);
}
if (resolved.startsWith("https://www.opinet.co.kr/api/aroundAll.do?")) {
return makeResponse(aroundResponse, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000001")) {
return makeResponse(detailA1000001, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchCheapGasStationsByLocationQuery("강남역", {
apiKey: "test-opinet-key",
limit: 1,
detailLimit: 1,
fetchImpl
});
assert.equal(result.anchor.id, "2003");
assert.equal(result.anchor.name, "강남역 11번출구");
assert.deepEqual(
calls.filter((url) => url.startsWith("https://place-api.map.kakao.com/places/panel3/")),
[
"https://place-api.map.kakao.com/places/panel3/2002",
"https://place-api.map.kakao.com/places/panel3/2003"
]
);
});
test("searchCheapGasStationsByLocationQuery rejects non-numeric limit and detailLimit values instead of returning an empty list", async () => {
await assert.rejects(
searchCheapGasStationsByLocationQuery("37.55472,126.97068", {
apiKey: "test-opinet-key",
limit: "abc",
fetchImpl: async () => {
throw new Error("fetch should not run for invalid limit input");
}
}),
/limit must be a finite number/i,
);
await assert.rejects(
searchCheapGasStationsByLocationQuery("37.55472,126.97068", {
apiKey: "test-opinet-key",
detailLimit: "abc",
fetchImpl: async () => {
throw new Error("fetch should not run for invalid detailLimit input");
}
}),
/detailLimit must be a finite number/i,
);
});
function makeResponse(body, contentType) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: 200,
headers: {
"content-type": contentType
}
});
}

View file

@ -1,17 +1,19 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회와 서울 지하철 실시간 도착정보를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
@ -35,6 +37,16 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한강 수위 정보 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다.
## PM2 실행
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 환경변수로 다시 올라옵니다.

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/server.js && node --check test/airkorea.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/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",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,145 @@
const BLUER_BASE_URL = "https://www.bluer.co.kr";
const RESTAURANTS_MAP_URL = `${BLUER_BASE_URL}/restaurants/map`;
const CSRF_SOURCE_URL = `${BLUER_BASE_URL}/search/zone`;
const BROWSER_HEADERS = {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
};
const JSON_HEADERS = {
...BROWSER_HEADERS,
accept: "application/json, text/plain;q=0.9,*/*;q=0.8",
"x-requested-with": "XMLHttpRequest"
};
const CSRF_META_PATTERN = /<meta\s+name="_csrf"\s+content="([^"]+)"/;
function generateApiToken(csrf) {
const prefix = "k7x9m2";
const suffix = "2m9x7k";
const raw = `${prefix}${csrf.substring(0, 8)}:${Date.now()}:${suffix}`;
return Buffer.from(raw).toString("base64");
}
async function fetchCsrfToken(sessionId, fetchImpl = global.fetch) {
const response = await fetchImpl(CSRF_SOURCE_URL, {
headers: {
...BROWSER_HEADERS,
cookie: `JSESSIONID=${sessionId}`
},
signal: AbortSignal.timeout(15000)
});
if (!response.ok) {
const error = new Error(`Failed to fetch CSRF token: HTTP ${response.status}`);
error.code = "csrf_fetch_failed";
error.statusCode = response.status;
throw error;
}
const html = await response.text();
const match = html.match(CSRF_META_PATTERN);
if (!match) {
const error = new Error(
"CSRF token not found in page. The session may have expired — re-login and update BLUE_RIBBON_SESSION_ID."
);
error.code = "csrf_not_found";
throw error;
}
return match[1];
}
function buildBoundingBox(latitude, longitude, distanceMeters) {
const latDelta = distanceMeters / 111320;
const lngDelta = distanceMeters / (111320 * Math.cos((latitude * Math.PI) / 180));
return {
latitude1: String(latitude - latDelta),
latitude2: String(latitude + latDelta),
longitude1: String(longitude - lngDelta),
longitude2: String(longitude + lngDelta)
};
}
async function proxyBlueRibbonNearbyRequest({
latitude,
longitude,
distanceMeters = 1000,
limit = 10,
sessionId,
fetchImpl = global.fetch
}) {
const csrf = await fetchCsrfToken(sessionId, fetchImpl);
const apiToken = generateApiToken(csrf);
const url = new URL(RESTAURANTS_MAP_URL);
const bbox = buildBoundingBox(latitude, longitude, distanceMeters);
for (const [key, value] of Object.entries(bbox)) {
url.searchParams.set(key, value);
}
url.searchParams.set("distance", String(distanceMeters));
url.searchParams.set("isAround", "true");
url.searchParams.set("ribbon", "true");
url.searchParams.set("ribbonType", "RIBBON_THREE,RIBBON_TWO,RIBBON_ONE");
url.searchParams.set("sort", "distance");
const response = await fetchImpl(url.toString(), {
headers: {
...JSON_HEADERS,
cookie: `JSESSIONID=${sessionId}`,
"x-csrf-token": csrf,
"x-api-token": apiToken
},
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
// ignore parse errors
}
if (response.status === 403 && payload?.error === "PREMIUM_REQUIRED") {
const error = new Error(
"Blue Ribbon session does not have premium access. Check BLUE_RIBBON_SESSION_ID."
);
error.code = "premium_required";
error.statusCode = 403;
throw error;
}
const error = new Error(`Blue Ribbon upstream returned HTTP ${response.status}`);
error.code = "upstream_error";
error.statusCode = response.status;
throw error;
}
const payload = await response.json();
const items = (payload.items || []).slice(0, limit);
return {
items,
total: Number(payload.total || items.length),
capped: Boolean(payload.capped)
};
}
module.exports = {
BLUER_BASE_URL,
CSRF_SOURCE_URL,
RESTAURANTS_MAP_URL,
buildBoundingBox,
fetchCsrfToken,
generateApiToken,
proxyBlueRibbonNearbyRequest
};

View file

@ -0,0 +1,241 @@
const HRFCO_API_BASE_URL = "https://api.hrfco.go.kr";
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function parseNumber(value) {
const trimmed = trimOrNull(value);
if (trimmed === null) {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeToken(value) {
return trimOrNull(value)?.toLowerCase().replace(/\s+/g, "") || null;
}
function extractStationNameVariants(station) {
const baseName = trimOrNull(station?.obsnm);
if (!baseName) {
return [];
}
const variants = new Set([baseName]);
for (const match of baseName.matchAll(/\(([^()]+)\)/g)) {
const inner = trimOrNull(match[1]);
if (inner) {
variants.add(inner);
}
}
return [...variants];
}
function buildError({ message, statusCode, code, candidateStations = null }) {
const error = new Error(message);
error.statusCode = statusCode;
error.code = code;
if (candidateStations) {
error.candidateStations = candidateStations;
}
return error;
}
function formatObservedAt(ymdhm) {
const raw = trimOrNull(ymdhm);
if (!raw || !/^\d{12}$/.test(raw)) {
return null;
}
const year = raw.slice(0, 4);
const month = raw.slice(4, 6);
const day = raw.slice(6, 8);
const hour = raw.slice(8, 10);
const minute = raw.slice(10, 12);
return `${year}-${month}-${day}T${hour}:${minute}:00+09:00`;
}
function dedupeStations(stations) {
return [...new Map(stations.map((station) => [station.wlobscd, station])).values()];
}
function pickWaterLevelStation(stationItems, { stationName = null, stationCode = null } = {}) {
const normalizedCode = trimOrNull(stationCode);
const normalizedName = normalizeToken(stationName);
const stations = Array.isArray(stationItems) ? stationItems : [];
if (normalizedCode) {
const byCode = stations.find((station) => trimOrNull(station.wlobscd) === normalizedCode);
if (!byCode) {
throw buildError({
message: "No HRFCO water-level station matched that stationCode.",
statusCode: 404,
code: "station_not_found"
});
}
return byCode;
}
if (!normalizedName) {
throw buildError({
message: "Provide stationName or stationCode.",
statusCode: 400,
code: "bad_request"
});
}
const exactMatches = dedupeStations(
stations.filter((station) =>
extractStationNameVariants(station)
.map((value) => normalizeToken(value))
.includes(normalizedName)
)
);
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
throw buildError({
message: "Multiple HRFCO water-level stations matched that stationName.",
statusCode: 400,
code: "ambiguous_station",
candidateStations: exactMatches.map((station) => station.obsnm).slice(0, 10)
});
}
const partialMatches = dedupeStations(
stations.filter((station) => {
const fields = [...extractStationNameVariants(station), station.addr, station.etcaddr]
.map((value) => normalizeToken(value))
.filter(Boolean);
return fields.some((field) => field.includes(normalizedName));
})
);
if (partialMatches.length === 0) {
throw buildError({
message: "No HRFCO water-level station matched that stationName.",
statusCode: 404,
code: "station_not_found"
});
}
if (partialMatches.length > 1) {
throw buildError({
message: "Multiple HRFCO water-level stations matched that stationName.",
statusCode: 400,
code: "ambiguous_station",
candidateStations: partialMatches.map((station) => station.obsnm).slice(0, 10)
});
}
return partialMatches[0];
}
function buildWaterLevelReport({ stationItems, measurementItems, stationName = null, stationCode = null }) {
const station = pickWaterLevelStation(stationItems, { stationName, stationCode });
const measurement = (Array.isArray(measurementItems) ? measurementItems : []).find(
(item) => trimOrNull(item.wlobscd) === trimOrNull(station.wlobscd)
);
if (!measurement) {
throw buildError({
message: "No current HRFCO water-level measurement was available for that station.",
statusCode: 404,
code: "measurement_not_found"
});
}
return {
station_code: station.wlobscd,
station_name: trimOrNull(station.obsnm),
agency_name: trimOrNull(station.agcnm),
address: [trimOrNull(station.addr), trimOrNull(station.etcaddr)].filter(Boolean).join(" ") || null,
observed_at: formatObservedAt(measurement.ymdhm),
observed_at_raw: trimOrNull(measurement.ymdhm),
water_level: {
value_m: parseNumber(measurement.wl),
unit: "m"
},
flow_rate: {
value_cms: parseNumber(measurement.fw),
unit: "m^3/s"
},
thresholds: {
interest_level_m: parseNumber(station.attwl),
warning_level_m: parseNumber(station.wrnwl),
alarm_level_m: parseNumber(station.almwl),
serious_level_m: parseNumber(station.srswl),
plan_flood_level_m: parseNumber(station.pfh)
},
special_report_station: trimOrNull(station.fstnyn) === "Y",
source: {
provider: "hrfco",
hydro_type: "waterlevel",
time_type: "10M"
}
};
}
async function fetchJson(url, { fetchImpl = global.fetch } = {}) {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
throw buildError({
message: `HRFCO upstream request failed with status ${response.status}.`,
statusCode: 502,
code: "upstream_error"
});
}
return response.json();
}
async function fetchWaterLevelStations({ serviceKey, fetchImpl = global.fetch }) {
const url = new URL(`${HRFCO_API_BASE_URL}/${serviceKey}/waterlevel/info.json`);
const payload = await fetchJson(url, { fetchImpl });
return Array.isArray(payload.content) ? payload.content : [];
}
async function fetchLatestWaterLevel({ serviceKey, stationCode, fetchImpl = global.fetch }) {
const url = new URL(`${HRFCO_API_BASE_URL}/${serviceKey}/waterlevel/list/10M/${stationCode}.json`);
const payload = await fetchJson(url, { fetchImpl });
return Array.isArray(payload.content) ? payload.content : [];
}
async function fetchWaterLevelReport({ stationName = null, stationCode = null, serviceKey, fetchImpl = global.fetch }) {
const stations = await fetchWaterLevelStations({ serviceKey, fetchImpl });
const station = pickWaterLevelStation(stations, { stationName, stationCode });
const measurements = await fetchLatestWaterLevel({
serviceKey,
stationCode: station.wlobscd,
fetchImpl
});
return buildWaterLevelReport({
stationItems: stations,
measurementItems: measurements,
stationCode: station.wlobscd
});
}
module.exports = {
HRFCO_API_BASE_URL,
buildWaterLevelReport,
fetchLatestWaterLevel,
fetchWaterLevelReport,
fetchWaterLevelStations,
formatObservedAt,
pickWaterLevelStation
};

View file

@ -0,0 +1,258 @@
// MOLIT (Ministry of Land, Infrastructure and Transport) real estate API wrapper.
// Proxies data.go.kr XML endpoints for Korean real estate transaction data.
const MOLIT_BASE_URL = "http://apis.data.go.kr/1613000";
const ENDPOINT_MAP = new Map([
["apartment/trade", "RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade"],
["apartment/rent", "RTMSDataSvcAptRent/getRTMSDataSvcAptRent"],
["officetel/trade", "RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade"],
["officetel/rent", "RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent"],
["villa/trade", "RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade"],
["villa/rent", "RTMSDataSvcRHRent/getRTMSDataSvcRHRent"],
["single-house/trade", "RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade"],
["single-house/rent", "RTMSDataSvcSHRent/getRTMSDataSvcSHRent"],
["commercial/trade", "RTMSDataSvcNrgTrade/getRTMSDataSvcNrgTrade"],
]);
const VALID_ASSET_TYPES = new Set(["apartment", "officetel", "villa", "single-house", "commercial"]);
const VALID_DEAL_TYPES = new Set(["trade", "rent"]);
// XML tag → JSON key mapping per asset type for trade responses.
// name_tag: XML tag for the property name
// area_tag: XML tag for the area field
// cancel_tag: XML tag for cancellation marker
// extra_fields: additional fields specific to the asset type
const TRADE_SCHEMA = {
apartment: { name_tag: "aptNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: [] },
officetel: { name_tag: "offiNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: [] },
villa: { name_tag: "mhouseNm", area_tag: "excluUseAr", cancel_tag: "cdealType", extra_fields: ["houseType"] },
"single-house": { name_tag: null, area_tag: "totalFloorAr", cancel_tag: "cdealType", extra_fields: ["houseType"], floor_fixed: 0 },
commercial: { name_tag: null, area_tag: "buildingAr", cancel_tag: "cdealtype", extra_fields: ["buildingType", "buildingUse", "landUse", "shareDealingType"] },
};
const RENT_SCHEMA = {
apartment: { name_tag: "aptNm", area_tag: "excluUseAr", extra_fields: [] },
officetel: { name_tag: "offiNm", area_tag: "excluUseAr", extra_fields: [] },
villa: { name_tag: "mhouseNm", area_tag: "excluUseAr", extra_fields: ["houseType"] },
"single-house": { name_tag: null, area_tag: "totalFloorAr", extra_fields: ["houseType"] },
};
function extractTag(itemXml, tagName) {
const re = new RegExp(`<${tagName}>\\s*([^<]*)\\s*</${tagName}>`);
const m = itemXml.match(re);
return m ? m[1].trim() : "";
}
function parseAmount(raw) {
const cleaned = raw.replace(/,/g, "");
const n = parseInt(cleaned, 10);
return Number.isFinite(n) ? n : null;
}
function parseFloatValue(raw) {
const n = parseFloat(raw);
return Number.isFinite(n) ? n : 0;
}
function parseIntValue(raw) {
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : 0;
}
function makeDate(itemXml) {
const year = extractTag(itemXml, "dealYear");
const month = extractTag(itemXml, "dealMonth").padStart(2, "0");
const day = extractTag(itemXml, "dealDay").padStart(2, "0");
return year ? `${year}-${month}-${day}` : "";
}
// Regex-based XML parser for MOLIT's flat <item> structure.
// Not a general-purpose XML parser — sufficient for data.go.kr MOLIT responses.
function parseXmlItems(xmlText) {
const codeMatch = xmlText.match(/<resultCode>(\d+)<\/resultCode>/);
if (!codeMatch) {
return { error: "parse_error", message: "No resultCode in response" };
}
const resultCode = codeMatch[1];
if (resultCode !== "000") {
const msgMatch = xmlText.match(/<resultMsg>([^<]*)<\/resultMsg>/);
const resultMsg = msgMatch ? msgMatch[1].trim() : `API error code ${resultCode}`;
return { error: `molit_api_${resultCode}`, message: resultMsg };
}
const totalMatch = xmlText.match(/<totalCount>(\d+)<\/totalCount>/);
const totalCount = totalMatch ? parseInt(totalMatch[1], 10) : 0;
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xmlText)) !== null) {
items.push(match[1]);
}
return { totalCount, items };
}
function normalizeTradeItem(itemXml, assetType) {
const schema = TRADE_SCHEMA[assetType];
if (!schema) return null;
const cancelVal = extractTag(itemXml, schema.cancel_tag);
if (cancelVal === "O") return null;
const price = parseAmount(extractTag(itemXml, "dealAmount"));
if (price === null) return null;
const result = {
name: schema.name_tag ? extractTag(itemXml, schema.name_tag) : "",
district: extractTag(itemXml, "umdNm"),
area_m2: parseFloatValue(extractTag(itemXml, schema.area_tag)),
floor: schema.floor_fixed !== undefined ? schema.floor_fixed : parseIntValue(extractTag(itemXml, "floor")),
price_10k: price,
deal_date: makeDate(itemXml),
build_year: parseIntValue(extractTag(itemXml, "buildYear")),
deal_type: extractTag(itemXml, "dealingGbn"),
};
for (const field of schema.extra_fields) {
result[field] = extractTag(itemXml, field);
}
return result;
}
function normalizeRentItem(itemXml, assetType) {
const schema = RENT_SCHEMA[assetType];
if (!schema) return null;
const cancelVal = extractTag(itemXml, "cdealType");
if (cancelVal === "O") return null;
const deposit = parseAmount(extractTag(itemXml, "deposit"));
if (deposit === null) return null;
const monthlyRentRaw = extractTag(itemXml, "monthlyRent");
const monthlyRent = monthlyRentRaw ? (parseAmount(monthlyRentRaw) || 0) : 0;
const result = {
name: schema.name_tag ? extractTag(itemXml, schema.name_tag) : "",
district: extractTag(itemXml, "umdNm"),
area_m2: parseFloatValue(extractTag(itemXml, schema.area_tag)),
floor: parseIntValue(extractTag(itemXml, "floor")),
deposit_10k: deposit,
monthly_rent_10k: monthlyRent,
contract_type: extractTag(itemXml, "contractType"),
deal_date: makeDate(itemXml),
build_year: parseIntValue(extractTag(itemXml, "buildYear")),
};
for (const field of schema.extra_fields) {
result[field] = extractTag(itemXml, field);
}
return result;
}
function median(arr) {
if (arr.length === 0) return 0;
const sorted = [...arr].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 !== 0 ? sorted[mid] : Math.floor((sorted[mid - 1] + sorted[mid]) / 2);
}
function mean(arr) {
if (arr.length === 0) return 0;
return Math.floor(arr.reduce((s, v) => s + v, 0) / arr.length);
}
function computeTradeSummary(items) {
if (items.length === 0) {
return { median_price_10k: 0, min_price_10k: 0, max_price_10k: 0, sample_count: 0 };
}
const prices = items.map((it) => it.price_10k);
return {
median_price_10k: median(prices),
min_price_10k: Math.min(...prices),
max_price_10k: Math.max(...prices),
sample_count: prices.length,
};
}
function computeRentSummary(items) {
if (items.length === 0) {
return { median_deposit_10k: 0, min_deposit_10k: 0, max_deposit_10k: 0, monthly_rent_avg_10k: 0, sample_count: 0 };
}
const deposits = items.map((it) => it.deposit_10k);
const rents = items.map((it) => it.monthly_rent_10k);
return {
median_deposit_10k: median(deposits),
min_deposit_10k: Math.min(...deposits),
max_deposit_10k: Math.max(...deposits),
monthly_rent_avg_10k: mean(rents),
sample_count: deposits.length,
};
}
async function fetchTransactions({ assetType, dealType, lawdCd, dealYmd, numOfRows = 100, serviceKey, fetchImpl }) {
const endpointKey = `${assetType}/${dealType}`;
const path = ENDPOINT_MAP.get(endpointKey);
if (!path) {
return { error: "invalid_endpoint", message: `Unknown endpoint: ${endpointKey}` };
}
const url = new URL(`${MOLIT_BASE_URL}/${path}`);
url.searchParams.set("LAWD_CD", lawdCd);
url.searchParams.set("DEAL_YMD", dealYmd);
url.searchParams.set("numOfRows", String(numOfRows));
url.searchParams.set("pageNo", "1");
url.searchParams.set("serviceKey", serviceKey);
const doFetch = fetchImpl || globalThis.fetch;
let response;
try {
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
} catch (err) {
return { error: "upstream_timeout", message: `Upstream request failed: ${err.message}` };
}
if (!response.ok) {
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
}
const xmlText = await response.text();
const parsed = parseXmlItems(xmlText);
if (parsed.error) {
return parsed;
}
const normalize = dealType === "trade" ? normalizeTradeItem : normalizeRentItem;
const items = [];
for (const rawItem of parsed.items) {
const normalized = normalize(rawItem, assetType);
if (normalized) items.push(normalized);
}
const summary = dealType === "trade" ? computeTradeSummary(items) : computeRentSummary(items);
return {
items,
summary,
total_count: parsed.totalCount,
filtered_count: items.length,
};
}
module.exports = {
ENDPOINT_MAP,
VALID_ASSET_TYPES,
VALID_DEAL_TYPES,
parseXmlItems,
extractTag,
normalizeTradeItem,
normalizeRentItem,
computeTradeSummary,
computeRentSummary,
fetchTransactions,
median,
};

View file

@ -0,0 +1,286 @@
{
"11000": "서울특별시",
"11110": "서울특별시 종로구",
"11140": "서울특별시 중구",
"11170": "서울특별시 용산구",
"11200": "서울특별시 성동구",
"11215": "서울특별시 광진구",
"11230": "서울특별시 동대문구",
"11260": "서울특별시 중랑구",
"11290": "서울특별시 성북구",
"11305": "서울특별시 강북구",
"11320": "서울특별시 도봉구",
"11350": "서울특별시 노원구",
"11380": "서울특별시 은평구",
"11410": "서울특별시 서대문구",
"11440": "서울특별시 마포구",
"11470": "서울특별시 양천구",
"11500": "서울특별시 강서구",
"11530": "서울특별시 구로구",
"11545": "서울특별시 금천구",
"11560": "서울특별시 영등포구",
"11590": "서울특별시 동작구",
"11620": "서울특별시 관악구",
"11650": "서울특별시 서초구",
"11680": "서울특별시 강남구",
"11710": "서울특별시 송파구",
"11740": "서울특별시 강동구",
"26000": "부산광역시",
"26110": "부산광역시 중구",
"26140": "부산광역시 서구",
"26170": "부산광역시 동구",
"26200": "부산광역시 영도구",
"26230": "부산광역시 부산진구",
"26260": "부산광역시 동래구",
"26290": "부산광역시 남구",
"26320": "부산광역시 북구",
"26350": "부산광역시 해운대구",
"26380": "부산광역시 사하구",
"26410": "부산광역시 금정구",
"26440": "부산광역시 강서구",
"26470": "부산광역시 연제구",
"26500": "부산광역시 수영구",
"26530": "부산광역시 사상구",
"26710": "부산광역시 기장군",
"27000": "대구광역시",
"27110": "대구광역시 중구",
"27140": "대구광역시 동구",
"27170": "대구광역시 서구",
"27200": "대구광역시 남구",
"27230": "대구광역시 북구",
"27260": "대구광역시 수성구",
"27290": "대구광역시 달서구",
"27710": "대구광역시 달성군",
"27720": "대구광역시 군위군",
"28000": "인천광역시",
"28110": "인천광역시 중구",
"28140": "인천광역시 동구",
"28177": "인천광역시 미추홀구",
"28185": "인천광역시 연수구",
"28200": "인천광역시 남동구",
"28237": "인천광역시 부평구",
"28245": "인천광역시 계양구",
"28260": "인천광역시 서구",
"28710": "인천광역시 강화군",
"28720": "인천광역시 옹진군",
"29000": "광주광역시",
"29110": "광주광역시 동구",
"29140": "광주광역시 서구",
"29155": "광주광역시 남구",
"29170": "광주광역시 북구",
"29200": "광주광역시 광산구",
"30000": "대전광역시",
"30110": "대전광역시 동구",
"30140": "대전광역시 중구",
"30170": "대전광역시 서구",
"30200": "대전광역시 유성구",
"30230": "대전광역시 대덕구",
"31000": "울산광역시",
"31110": "울산광역시 중구",
"31140": "울산광역시 남구",
"31170": "울산광역시 동구",
"31200": "울산광역시 북구",
"31710": "울산광역시 울주군",
"36110": "세종특별자치시",
"41000": "경기도",
"41110": "경기도 수원시",
"41111": "경기도 수원시 장안구",
"41113": "경기도 수원시 권선구",
"41115": "경기도 수원시 팔달구",
"41117": "경기도 수원시 영통구",
"41130": "경기도 성남시",
"41131": "경기도 성남시 수정구",
"41133": "경기도 성남시 중원구",
"41135": "경기도 성남시 분당구",
"41150": "경기도 의정부시",
"41170": "경기도 안양시",
"41171": "경기도 안양시 만안구",
"41173": "경기도 안양시 동안구",
"41190": "경기도 부천시",
"41192": "경기도 부천시 원미구",
"41194": "경기도 부천시 소사구",
"41196": "경기도 부천시 오정구",
"41210": "경기도 광명시",
"41220": "경기도 평택시",
"41250": "경기도 동두천시",
"41270": "경기도 안산시",
"41271": "경기도 안산시 상록구",
"41273": "경기도 안산시 단원구",
"41280": "경기도 고양시",
"41281": "경기도 고양시 덕양구",
"41285": "경기도 고양시 일산동구",
"41287": "경기도 고양시 일산서구",
"41290": "경기도 과천시",
"41310": "경기도 구리시",
"41360": "경기도 남양주시",
"41370": "경기도 오산시",
"41390": "경기도 시흥시",
"41410": "경기도 군포시",
"41430": "경기도 의왕시",
"41450": "경기도 하남시",
"41460": "경기도 용인시",
"41461": "경기도 용인시 처인구",
"41463": "경기도 용인시 기흥구",
"41465": "경기도 용인시 수지구",
"41480": "경기도 파주시",
"41500": "경기도 이천시",
"41550": "경기도 안성시",
"41570": "경기도 김포시",
"41590": "경기도 화성시",
"41591": "경기도 화성시 만세구",
"41593": "경기도 화성시 효행구",
"41595": "경기도 화성시 병점구",
"41597": "경기도 화성시 동탄구",
"41610": "경기도 광주시",
"41630": "경기도 양주시",
"41650": "경기도 포천시",
"41670": "경기도 여주시",
"41800": "경기도 연천군",
"41820": "경기도 가평군",
"41830": "경기도 양평군",
"43000": "충청북도",
"43110": "충청북도 청주시",
"43111": "충청북도 청주시 상당구",
"43112": "충청북도 청주시 서원구",
"43113": "충청북도 청주시 흥덕구",
"43114": "충청북도 청주시 청원구",
"43130": "충청북도 충주시",
"43150": "충청북도 제천시",
"43720": "충청북도 보은군",
"43730": "충청북도 옥천군",
"43740": "충청북도 영동군",
"43745": "충청북도 증평군",
"43750": "충청북도 진천군",
"43760": "충청북도 괴산군",
"43770": "충청북도 음성군",
"43800": "충청북도 단양군",
"44000": "충청남도",
"44130": "충청남도 천안시",
"44131": "충청남도 천안시 동남구",
"44133": "충청남도 천안시 서북구",
"44150": "충청남도 공주시",
"44180": "충청남도 보령시",
"44200": "충청남도 아산시",
"44210": "충청남도 서산시",
"44230": "충청남도 논산시",
"44250": "충청남도 계룡시",
"44270": "충청남도 당진시",
"44710": "충청남도 금산군",
"44760": "충청남도 부여군",
"44770": "충청남도 서천군",
"44790": "충청남도 청양군",
"44800": "충청남도 홍성군",
"44810": "충청남도 예산군",
"44825": "충청남도 태안군",
"46000": "전라남도",
"46110": "전라남도 목포시",
"46130": "전라남도 여수시",
"46150": "전라남도 순천시",
"46170": "전라남도 나주시",
"46230": "전라남도 광양시",
"46710": "전라남도 담양군",
"46720": "전라남도 곡성군",
"46730": "전라남도 구례군",
"46770": "전라남도 고흥군",
"46780": "전라남도 보성군",
"46790": "전라남도 화순군",
"46800": "전라남도 장흥군",
"46810": "전라남도 강진군",
"46820": "전라남도 해남군",
"46830": "전라남도 영암군",
"46840": "전라남도 무안군",
"46860": "전라남도 함평군",
"46870": "전라남도 영광군",
"46880": "전라남도 장성군",
"46890": "전라남도 완도군",
"46900": "전라남도 진도군",
"46910": "전라남도 신안군",
"47000": "경상북도",
"47110": "경상북도 포항시",
"47111": "경상북도 포항시 남구",
"47113": "경상북도 포항시 북구",
"47130": "경상북도 경주시",
"47150": "경상북도 김천시",
"47170": "경상북도 안동시",
"47190": "경상북도 구미시",
"47210": "경상북도 영주시",
"47230": "경상북도 영천시",
"47250": "경상북도 상주시",
"47280": "경상북도 문경시",
"47290": "경상북도 경산시",
"47730": "경상북도 의성군",
"47750": "경상북도 청송군",
"47760": "경상북도 영양군",
"47770": "경상북도 영덕군",
"47820": "경상북도 청도군",
"47830": "경상북도 고령군",
"47840": "경상북도 성주군",
"47850": "경상북도 칠곡군",
"47900": "경상북도 예천군",
"47920": "경상북도 봉화군",
"47930": "경상북도 울진군",
"47940": "경상북도 울릉군",
"48000": "경상남도",
"48120": "경상남도 창원시",
"48121": "경상남도 창원시 의창구",
"48123": "경상남도 창원시 성산구",
"48125": "경상남도 창원시 마산합포구",
"48127": "경상남도 창원시 마산회원구",
"48129": "경상남도 창원시 진해구",
"48170": "경상남도 진주시",
"48220": "경상남도 통영시",
"48240": "경상남도 사천시",
"48250": "경상남도 김해시",
"48270": "경상남도 밀양시",
"48310": "경상남도 거제시",
"48330": "경상남도 양산시",
"48720": "경상남도 의령군",
"48730": "경상남도 함안군",
"48740": "경상남도 창녕군",
"48820": "경상남도 고성군",
"48840": "경상남도 남해군",
"48850": "경상남도 하동군",
"48860": "경상남도 산청군",
"48870": "경상남도 함양군",
"48880": "경상남도 거창군",
"48890": "경상남도 합천군",
"50000": "제주특별자치도",
"50110": "제주특별자치도 제주시",
"50130": "제주특별자치도 서귀포시",
"51000": "강원특별자치도",
"51110": "강원특별자치도 춘천시",
"51130": "강원특별자치도 원주시",
"51150": "강원특별자치도 강릉시",
"51170": "강원특별자치도 동해시",
"51190": "강원특별자치도 태백시",
"51210": "강원특별자치도 속초시",
"51230": "강원특별자치도 삼척시",
"51720": "강원특별자치도 홍천군",
"51730": "강원특별자치도 횡성군",
"51750": "강원특별자치도 영월군",
"51760": "강원특별자치도 평창군",
"51770": "강원특별자치도 정선군",
"51780": "강원특별자치도 철원군",
"51790": "강원특별자치도 화천군",
"51800": "강원특별자치도 양구군",
"51810": "강원특별자치도 인제군",
"51820": "강원특별자치도 고성군",
"51830": "강원특별자치도 양양군",
"52000": "전북특별자치도",
"52110": "전북특별자치도 전주시",
"52111": "전북특별자치도 전주시 완산구",
"52113": "전북특별자치도 전주시 덕진구",
"52130": "전북특별자치도 군산시",
"52140": "전북특별자치도 익산시",
"52180": "전북특별자치도 정읍시",
"52190": "전북특별자치도 남원시",
"52210": "전북특별자치도 김제시",
"52710": "전북특별자치도 완주군",
"52720": "전북특별자치도 진안군",
"52730": "전북특별자치도 무주군",
"52740": "전북특별자치도 장수군",
"52750": "전북특별자치도 임실군",
"52770": "전북특별자치도 순창군",
"52790": "전북특별자치도 고창군",
"52800": "전북특별자치도 부안군"
}

View file

@ -0,0 +1,33 @@
// Region code lookup: resolves free-text Korean address queries to 5-digit
// LAWD_CD codes used by MOLIT real estate APIs.
let regionData = null;
function loadRegionCodes() {
if (!regionData) {
const raw = require("./region-codes.json");
regionData = Object.entries(raw).map(([lawd_cd, name]) => ({ lawd_cd, name }));
}
return regionData;
}
function searchRegionCode(query) {
if (!query || typeof query !== "string") return [];
const tokens = query.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) return [];
const entries = loadRegionCodes();
const results = [];
for (const entry of entries) {
if (tokens.every((tok) => entry.name.includes(tok))) {
results.push(entry);
if (results.length >= 10) break;
}
}
return results;
}
module.exports = { searchRegionCode, loadRegionCodes };

View file

@ -1,8 +1,13 @@
const crypto = require("node:crypto");
const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
@ -42,6 +47,10 @@ function buildConfig(env = process.env) {
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -142,6 +151,92 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeOpinetAroundQuery(query) {
const x = parseFloatValue(query.x);
const y = parseFloatValue(query.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as KATEC coordinates.");
}
const radius = parseInteger(query.radius, 1000);
if (radius <= 0 || radius > 5000) {
throw new Error("radius must be between 1 and 5000.");
}
const prodcd = trimOrNull(query.prodcd) || "B027";
const sort = parseInteger(query.sort, 1);
return { x, y, radius, prodcd, sort };
}
function normalizeOpinetDetailQuery(query) {
const id = trimOrNull(query.id);
if (!id) {
throw new Error("Provide id.");
}
return { id };
}
function normalizeBlueRibbonNearbyQuery(query) {
const latitude = parseFloatValue(query.latitude ?? query.lat);
const longitude = parseFloatValue(query.longitude ?? query.lng);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("Provide latitude and longitude.");
}
const distanceMeters = parseInteger(query.distanceMeters ?? query.distance, 1000);
if (distanceMeters <= 0 || distanceMeters > 5000) {
throw new Error("distanceMeters must be between 1 and 5000.");
}
const limit = parseInteger(query.limit, 10);
if (limit <= 0 || limit > 50) {
throw new Error("limit must be between 1 and 50.");
}
return { latitude, longitude, distanceMeters, limit };
}
function normalizeRealEstateQuery(query) {
const lawdCd = trimOrNull(query.lawd_cd ?? query.lawdCd);
if (!lawdCd || !/^\d{5}$/.test(lawdCd)) {
throw new Error("Provide lawd_cd as a 5-digit region code.");
}
const dealYmd = trimOrNull(query.deal_ymd ?? query.dealYmd);
if (!dealYmd || !/^\d{6}$/.test(dealYmd)) {
throw new Error("Provide deal_ymd as YYYYMM.");
}
const numOfRows = parseInteger(query.num_of_rows ?? query.numOfRows, 100);
if (numOfRows < 1 || numOfRows > 1000) {
throw new Error("num_of_rows must be between 1 and 1000.");
}
return { lawdCd, dealYmd, numOfRows };
}
function normalizeRegionCodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide q (region name query).");
}
return { q };
}
function normalizeHanRiverWaterLevelQuery(query) {
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
const stationCode = trimOrNull(query.stationCode ?? query.station_code ?? query.wlobscd);
if (!stationName && !stationCode) {
throw new Error("Provide stationName or stationCode.");
}
return {
stationName,
stationCode
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
}
@ -228,6 +323,85 @@ async function proxySeoulSubwayRequest({
};
}
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "OPINET_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${OPINET_API_BASE_URL}/${path}`);
url.searchParams.set("out", "json");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
url.searchParams.set("certkey", apiKey);
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 proxyHrfcoWaterLevelRequest({
stationName = null,
stationCode = null,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "HRFCO_OPEN_API_KEY is not configured on the proxy server."
})
};
}
try {
const report = await fetchWaterLevelReport({
stationName,
stationCode,
serviceKey: apiKey,
fetchImpl
});
return {
statusCode: 200,
contentType: "application/json; charset=utf-8",
body: JSON.stringify(report)
};
} catch (error) {
const payload = {
error: error.code || "proxy_error",
message: error.message
};
if (Array.isArray(error.candidateStations)) {
payload.candidate_stations = error.candidateStations;
}
return {
statusCode: error.statusCode && error.statusCode >= 400 ? error.statusCode : 502,
contentType: "application/json; charset=utf-8",
body: JSON.stringify(payload)
};
}
}
function buildServer({ env = process.env, provider = null } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
@ -259,7 +433,11 @@ function buildServer({ env = process.env, provider = null } = {}) {
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey)
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey)
},
auth: {
tokenRequired: false
@ -401,6 +579,420 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/han-river/water-level", async (request, reply) => {
let normalized;
try {
normalized = normalizeHanRiverWaterLevelQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "han-river-water-level",
stationName: normalized.stationName?.toLowerCase() || null,
stationCode: normalized.stationCode || null
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyHrfcoWaterLevelRequest({
...normalized,
apiKey: config.hrfcoApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/blue-ribbon/nearby", async (request, reply) => {
let normalized;
try {
normalized = normalizeBlueRibbonNearbyQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
if (!config.blueRibbonSessionId) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "BLUE_RIBBON_SESSION_ID is not configured on the proxy server."
};
}
const cacheKey = makeCacheKey({
route: "blue-ribbon-nearby",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const result = await proxyBlueRibbonNearbyRequest({
...normalized,
sessionId: config.blueRibbonSessionId
});
const payload = {
...result,
query: normalized,
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/opinet/around", async (request, reply) => {
let normalized;
try {
normalized = normalizeOpinetAroundQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "opinet-around",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyOpinetRequest({
path: "aroundAll.do",
params: normalized,
apiKey: config.opinetApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/opinet/detail", async (request, reply) => {
let normalized;
try {
normalized = normalizeOpinetDetailQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "opinet-detail",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyOpinetRequest({
path: "detailById.do",
params: normalized,
apiKey: config.opinetApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/real-estate/region-code", async (request, reply) => {
let normalized;
try {
normalized = normalizeRegionCodeQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "real-estate-region-code",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const results = searchRegionCode(normalized.q);
const payload = {
results,
query: normalized.q,
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/real-estate/:assetType/:dealType", async (request, reply) => {
const { assetType, dealType } = request.params;
if (!VALID_ASSET_TYPES.has(assetType)) {
reply.code(404);
return {
error: "not_found",
message: `Unknown asset type: ${assetType}. Valid: apartment, officetel, villa, single-house, commercial`
};
}
if (!VALID_DEAL_TYPES.has(dealType)) {
reply.code(404);
return {
error: "not_found",
message: `Unknown deal type: ${dealType}. Valid: trade, rent`
};
}
if (assetType === "commercial" && dealType === "rent") {
reply.code(404);
return {
error: "not_found",
message: "commercial/rent is not available. Only commercial/trade is supported."
};
}
let normalized;
try {
normalized = normalizeRealEstateQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "real-estate",
assetType,
dealType,
...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
}
}
};
}
const result = await fetchTransactions({
assetType,
dealType,
lawdCd: normalized.lawdCd,
dealYmd: normalized.dealYmd,
numOfRows: normalized.numOfRows,
serviceKey: config.molitApiKey
});
if (result.error) {
reply.code(502);
return {
...result,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
}
const payload = {
...result,
query: {
asset_type: assetType,
deal_type: dealType,
lawd_cd: normalized.lawdCd,
deal_ymd: normalized.dealYmd
},
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.setErrorHandler((error, request, reply) => {
request.log.error(error);
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
@ -440,9 +1032,17 @@ if (require.main === module) {
module.exports = {
buildConfig,
buildServer,
normalizeBlueRibbonNearbyQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeRealEstateQuery,
normalizeRegionCodeQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyHrfcoWaterLevelRequest,
proxyOpinetRequest,
proxySeoulSubwayRequest,
startServer
};

View file

@ -0,0 +1,121 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildWaterLevelReport,
fetchWaterLevelReport,
pickWaterLevelStation
} = require("../src/hrfco");
const stationItems = [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교",
attwl: "5.5",
wrnwl: "8.0",
almwl: "10.0",
srswl: "11.0",
pfh: "13.0",
fstnyn: "Y"
},
{
wlobscd: "1018680",
obsnm: "한강철교",
agcnm: "한강홍수통제소",
addr: "서울특별시 동작구",
etcaddr: "한강철교"
}
];
const measurementItems = [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
];
test("pickWaterLevelStation prefers exact station code matches", () => {
const station = pickWaterLevelStation(stationItems, {
stationCode: "1018683"
});
assert.equal(station.obsnm, "한강대교");
});
test("pickWaterLevelStation returns candidate stations for ambiguous names", () => {
assert.throws(
() => pickWaterLevelStation(stationItems, { stationName: "한강" }),
(error) =>
error.code === "ambiguous_station" &&
Array.isArray(error.candidateStations) &&
error.candidateStations.includes("한강대교") &&
error.candidateStations.includes("한강철교")
);
});
test("pickWaterLevelStation matches parenthetical station aliases before broader partial matches", () => {
const station = pickWaterLevelStation(
[
{ wlobscd: "1005697", obsnm: "원주시(남한강대교)" },
{ wlobscd: "1018683", obsnm: "서울시(한강대교)" }
],
{ stationName: "한강대교" }
);
assert.equal(station.wlobscd, "1018683");
});
test("buildWaterLevelReport combines station metadata and latest measurement", () => {
const report = buildWaterLevelReport({
stationItems,
measurementItems,
stationName: "한강대교"
});
assert.equal(report.station_name, "한강대교");
assert.equal(report.station_code, "1018683");
assert.deepEqual(report.water_level, { value_m: 0.66, unit: "m" });
assert.deepEqual(report.flow_rate, { value_cms: 208.58, unit: "m^3/s" });
assert.equal(report.thresholds.warning_level_m, 8);
assert.equal(report.special_report_station, true);
});
test("fetchWaterLevelReport uses station info lookup before latest measurement lookup", async () => {
const calls = [];
const report = await fetchWaterLevelReport({
stationName: "한강대교",
serviceKey: "test-key",
fetchImpl: async (url) => {
const text = String(url);
calls.push(text);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(JSON.stringify({ content: stationItems }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(JSON.stringify({ content: measurementItems }), {
status: 200,
headers: { "content-type": "application/json" }
});
}
throw new Error(`unexpected URL: ${url}`);
}
});
assert.equal(report.station_name, "한강대교");
assert.equal(report.flow_rate.value_cms, 208.58);
assert.deepEqual(calls.map((url) => url.split("/").slice(-3).join("/")), [
"test-key/waterlevel/info.json",
"list/10M/1018683.json"
]);
});

View file

@ -0,0 +1,349 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseXmlItems,
extractTag,
normalizeTradeItem,
normalizeRentItem,
computeTradeSummary,
computeRentSummary,
fetchTransactions,
median,
} = require("../src/molit");
const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<response>
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
<body>
<items>
<item>
<aptNm>래미안</aptNm>
<umdNm>반포동</umdNm>
<excluUseAr>84.99</excluUseAr>
<floor>12</floor>
<dealAmount> 245,000</dealAmount>
<dealYear>2024</dealYear>
<dealMonth>3</dealMonth>
<dealDay>15</dealDay>
<buildYear>2009</buildYear>
<dealingGbn>중개거래</dealingGbn>
<cdealType></cdealType>
</item>
<item>
<aptNm>래미안</aptNm>
<umdNm>반포동</umdNm>
<excluUseAr>84.99</excluUseAr>
<floor>5</floor>
<dealAmount> 200,000</dealAmount>
<dealYear>2024</dealYear>
<dealMonth>3</dealMonth>
<dealDay>20</dealDay>
<buildYear>2009</buildYear>
<dealingGbn>직거래</dealingGbn>
<cdealType>O</cdealType>
</item>
<item>
<aptNm>아크로리버</aptNm>
<umdNm>반포동</umdNm>
<excluUseAr>59.96</excluUseAr>
<floor>3</floor>
<dealAmount> 180,000</dealAmount>
<dealYear>2024</dealYear>
<dealMonth>3</dealMonth>
<dealDay>22</dealDay>
<buildYear>2016</buildYear>
<dealingGbn>중개거래</dealingGbn>
<cdealType></cdealType>
</item>
</items>
<totalCount>3</totalCount>
</body>
</response>`;
const SAMPLE_APT_RENT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<response>
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
<body>
<items>
<item>
<aptNm>래미안</aptNm>
<umdNm>반포동</umdNm>
<excluUseAr>84.99</excluUseAr>
<floor>12</floor>
<deposit> 80,000</deposit>
<monthlyRent>0</monthlyRent>
<contractType>신규</contractType>
<dealYear>2024</dealYear>
<dealMonth>3</dealMonth>
<dealDay>10</dealDay>
<buildYear>2009</buildYear>
<cdealType></cdealType>
</item>
<item>
<aptNm>아크로리버</aptNm>
<umdNm>반포동</umdNm>
<excluUseAr>59.96</excluUseAr>
<floor>5</floor>
<deposit> 10,000</deposit>
<monthlyRent> 150</monthlyRent>
<contractType>갱신</contractType>
<dealYear>2024</dealYear>
<dealMonth>3</dealMonth>
<dealDay>15</dealDay>
<buildYear>2016</buildYear>
<cdealType></cdealType>
</item>
</items>
<totalCount>2</totalCount>
</body>
</response>`;
const ERROR_XML = `<?xml version="1.0" encoding="UTF-8"?>
<response>
<header><resultCode>030</resultCode><resultMsg> .</resultMsg></header>
</response>`;
test("parseXmlItems extracts items and totalCount from valid XML", () => {
const result = parseXmlItems(SAMPLE_APT_TRADE_XML);
assert.equal(result.error, undefined);
assert.equal(result.totalCount, 3);
assert.equal(result.items.length, 3);
});
test("parseXmlItems returns error for non-000 resultCode", () => {
const result = parseXmlItems(ERROR_XML);
assert.equal(result.error, "molit_api_030");
assert.ok(result.message.includes("등록되지 않은 서비스키"));
});
test("parseXmlItems returns error for missing resultCode", () => {
const result = parseXmlItems("<response><body></body></response>");
assert.equal(result.error, "parse_error");
});
test("extractTag extracts trimmed value", () => {
const xml = "<aptNm> 래미안 퍼스티지 </aptNm>";
assert.equal(extractTag(xml, "aptNm"), "래미안 퍼스티지");
});
test("extractTag returns empty string for missing tag", () => {
assert.equal(extractTag("<foo>bar</foo>", "missing"), "");
});
test("normalizeTradeItem parses apartment trade correctly", () => {
const parsed = parseXmlItems(SAMPLE_APT_TRADE_XML);
const item = normalizeTradeItem(parsed.items[0], "apartment");
assert.equal(item.name, "래미안");
assert.equal(item.district, "반포동");
assert.equal(item.area_m2, 84.99);
assert.equal(item.floor, 12);
assert.equal(item.price_10k, 245000);
assert.equal(item.deal_date, "2024-03-15");
assert.equal(item.build_year, 2009);
assert.equal(item.deal_type, "중개거래");
});
test("normalizeTradeItem filters cancelled deals", () => {
const parsed = parseXmlItems(SAMPLE_APT_TRADE_XML);
const item = normalizeTradeItem(parsed.items[1], "apartment");
assert.equal(item, null);
});
test("normalizeTradeItem uses offiNm for officetel", () => {
const xml = `<offiNm>오피스텔A</offiNm><umdNm>역삼동</umdNm><excluUseAr>33.5</excluUseAr>
<floor>7</floor><dealAmount>50,000</dealAmount><dealYear>2024</dealYear>
<dealMonth>1</dealMonth><dealDay>5</dealDay><buildYear>2020</buildYear>
<dealingGbn>중개거래</dealingGbn><cdealType></cdealType>`;
const item = normalizeTradeItem(xml, "officetel");
assert.equal(item.name, "오피스텔A");
});
test("normalizeTradeItem uses mhouseNm and houseType for villa", () => {
const xml = `<mhouseNm>빌라B</mhouseNm><umdNm>신림동</umdNm><excluUseAr>45.0</excluUseAr>
<floor>3</floor><dealAmount>30,000</dealAmount><dealYear>2024</dealYear>
<dealMonth>2</dealMonth><dealDay>10</dealDay><buildYear>2015</buildYear>
<dealingGbn>직거래</dealingGbn><cdealType></cdealType><houseType></houseType>`;
const item = normalizeTradeItem(xml, "villa");
assert.equal(item.name, "빌라B");
assert.equal(item.houseType, "다세대");
});
test("normalizeTradeItem uses totalFloorAr and floor=0 for single-house", () => {
const xml = `<umdNm>수유동</umdNm><totalFloorAr>120.5</totalFloorAr>
<dealAmount>70,000</dealAmount><dealYear>2024</dealYear>
<dealMonth>4</dealMonth><dealDay>1</dealDay><buildYear>1990</buildYear>
<dealingGbn>중개거래</dealingGbn><cdealType></cdealType><houseType></houseType>`;
const item = normalizeTradeItem(xml, "single-house");
assert.equal(item.name, "");
assert.equal(item.area_m2, 120.5);
assert.equal(item.floor, 0);
assert.equal(item.houseType, "단독");
});
test("normalizeTradeItem handles commercial with lowercase cdealtype", () => {
const xml = `<buildingType>업무시설</buildingType><buildingUse>오피스</buildingUse>
<landUse>상업지역</landUse><umdNm></umdNm><buildingAr>200.0</buildingAr>
<floor>10</floor><dealAmount>500,000</dealAmount><dealYear>2024</dealYear>
<dealMonth>5</dealMonth><dealDay>20</dealDay><buildYear>2018</buildYear>
<dealingGbn>중개거래</dealingGbn><cdealtype></cdealtype><shareDealingType></shareDealingType>`;
const item = normalizeTradeItem(xml, "commercial");
assert.equal(item.buildingType, "업무시설");
assert.equal(item.area_m2, 200.0);
assert.equal(item.price_10k, 500000);
});
test("normalizeRentItem parses apartment rent correctly", () => {
const parsed = parseXmlItems(SAMPLE_APT_RENT_XML);
const item = normalizeRentItem(parsed.items[0], "apartment");
assert.equal(item.name, "래미안");
assert.equal(item.deposit_10k, 80000);
assert.equal(item.monthly_rent_10k, 0);
assert.equal(item.contract_type, "신규");
assert.equal(item.deal_date, "2024-03-10");
});
test("normalizeRentItem parses monthly rent correctly", () => {
const parsed = parseXmlItems(SAMPLE_APT_RENT_XML);
const item = normalizeRentItem(parsed.items[1], "apartment");
assert.equal(item.deposit_10k, 10000);
assert.equal(item.monthly_rent_10k, 150);
});
test("median computes correctly for odd-length array", () => {
assert.equal(median([3, 1, 2]), 2);
});
test("median computes correctly for even-length array", () => {
assert.equal(median([1, 2, 3, 4]), 2);
});
test("median returns 0 for empty array", () => {
assert.equal(median([]), 0);
});
test("computeTradeSummary computes stats correctly", () => {
const items = [
{ price_10k: 100000 },
{ price_10k: 200000 },
{ price_10k: 300000 },
];
const summary = computeTradeSummary(items);
assert.equal(summary.median_price_10k, 200000);
assert.equal(summary.min_price_10k, 100000);
assert.equal(summary.max_price_10k, 300000);
assert.equal(summary.sample_count, 3);
});
test("computeTradeSummary returns zeros for empty array", () => {
const summary = computeTradeSummary([]);
assert.equal(summary.sample_count, 0);
assert.equal(summary.median_price_10k, 0);
});
test("computeRentSummary computes deposit and rent stats", () => {
const items = [
{ deposit_10k: 50000, monthly_rent_10k: 0 },
{ deposit_10k: 10000, monthly_rent_10k: 100 },
{ deposit_10k: 30000, monthly_rent_10k: 50 },
];
const summary = computeRentSummary(items);
assert.equal(summary.median_deposit_10k, 30000);
assert.equal(summary.min_deposit_10k, 10000);
assert.equal(summary.max_deposit_10k, 50000);
assert.equal(summary.monthly_rent_avg_10k, 50);
assert.equal(summary.sample_count, 3);
});
test("fetchTransactions returns error for invalid endpoint", async () => {
const result = await fetchTransactions({
assetType: "unknown",
dealType: "trade",
lawdCd: "11680",
dealYmd: "202403",
serviceKey: "key",
});
assert.equal(result.error, "invalid_endpoint");
});
test("fetchTransactions parses full XML pipeline", async () => {
const mockFetch = async () => ({
ok: true,
text: async () => SAMPLE_APT_TRADE_XML,
});
const result = await fetchTransactions({
assetType: "apartment",
dealType: "trade",
lawdCd: "11680",
dealYmd: "202403",
serviceKey: "test-key",
fetchImpl: mockFetch,
});
assert.equal(result.error, undefined);
assert.equal(result.items.length, 2); // 1 cancelled filtered out
assert.equal(result.total_count, 3);
assert.equal(result.filtered_count, 2);
assert.equal(result.items[0].name, "래미안");
assert.equal(result.items[0].price_10k, 245000);
assert.equal(result.summary.sample_count, 2);
});
test("fetchTransactions handles rent XML", async () => {
const mockFetch = async () => ({
ok: true,
text: async () => SAMPLE_APT_RENT_XML,
});
const result = await fetchTransactions({
assetType: "apartment",
dealType: "rent",
lawdCd: "11680",
dealYmd: "202403",
serviceKey: "test-key",
fetchImpl: mockFetch,
});
assert.equal(result.error, undefined);
assert.equal(result.items.length, 2);
assert.equal(result.items[0].deposit_10k, 80000);
assert.ok(result.summary.median_deposit_10k > 0);
});
test("fetchTransactions returns error for upstream failure", async () => {
const mockFetch = async () => ({
ok: false,
status: 500,
text: async () => "Internal Server Error",
});
const result = await fetchTransactions({
assetType: "apartment",
dealType: "trade",
lawdCd: "11680",
dealYmd: "202403",
serviceKey: "test-key",
fetchImpl: mockFetch,
});
assert.equal(result.error, "upstream_error");
});
test("fetchTransactions returns error for API error code", async () => {
const mockFetch = async () => ({
ok: true,
text: async () => ERROR_XML,
});
const result = await fetchTransactions({
assetType: "apartment",
dealType: "trade",
lawdCd: "11680",
dealYmd: "202403",
serviceKey: "test-key",
fetchImpl: mockFetch,
});
assert.equal(result.error, "molit_api_030");
});

View file

@ -0,0 +1,39 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { searchRegionCode } = require("../src/region-lookup");
test("searchRegionCode finds by single token", () => {
const results = searchRegionCode("강남구");
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.lawd_cd === "11680"));
assert.ok(results.every((r) => r.name.includes("강남구")));
});
test("searchRegionCode finds by multiple tokens", () => {
const results = searchRegionCode("서울 강남구");
assert.ok(results.length > 0);
assert.ok(results.every((r) => r.name.includes("서울") && r.name.includes("강남구")));
});
test("searchRegionCode returns empty for no match", () => {
const results = searchRegionCode("존재하지않는지역");
assert.equal(results.length, 0);
});
test("searchRegionCode returns empty for empty/null input", () => {
assert.equal(searchRegionCode("").length, 0);
assert.equal(searchRegionCode(null).length, 0);
assert.equal(searchRegionCode(undefined).length, 0);
});
test("searchRegionCode returns at most 10 results", () => {
const results = searchRegionCode("시");
assert.ok(results.length <= 10);
});
test("searchRegionCode finds 세종특별자치시", () => {
const results = searchRegionCode("세종");
assert.ok(results.length > 0);
assert.ok(results.some((r) => r.name.includes("세종")));
});

View file

@ -1,7 +1,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { buildServer, proxyAirKoreaRequest, proxySeoulSubwayRequest } = require("../src/server");
const {
buildServer,
proxyAirKoreaRequest,
proxySeoulSubwayRequest,
proxyHrfcoWaterLevelRequest
} = require("../src/server");
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
@ -322,3 +327,467 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
const text = String(url);
fetchCalls.push(text);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교",
attwl: "5.5",
wrnwl: "8.0",
almwl: "10.0",
srswl: "11.0",
pfh: "13.0",
fstnyn: "Y"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().station_name, "한강대교");
assert.equal(response.json().water_level.value_m, 0.66);
assert.equal(response.json().flow_rate.value_cms, 208.58);
assert.equal(response.json().proxy.cache.hit, false);
assert.match(fetchCalls[0], /\/waterlevel\/info\.json$/);
assert.match(fetchCalls[1], /\/waterlevel\/list\/10M\/1018683\.json$/);
});
test("han river water-level endpoint caches normalized station queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
obsnm: "한강대교",
agcnm: "한강홍수통제소",
addr: "서울특별시 용산구",
etcaddr: "한강대교"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("/waterlevel/list/10M/1018683.json")) {
return new Response(
JSON.stringify({
content: [
{
wlobscd: "1018683",
ymdhm: "202604051900",
wl: "0.66",
fw: "208.58"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-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/han-river/water-level?station=%20%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90%20"
});
const second = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 2);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("han river water-level endpoint returns ambiguous candidates for broad station names", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
const text = String(url);
if (text.endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" },
{ wlobscd: "1018680", obsnm: "한강철교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
throw new Error(`unexpected URL: ${url}`);
};
const app = buildServer({
env: {
HRFCO_OPEN_API_KEY: "hrfco-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "ambiguous_station");
assert.deepEqual(response.json().candidate_stations, ["한강대교", "한강철교"]);
});
test("han river water-level endpoint returns 503 when proxy server lacks HRFCO API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/han-river/water-level?stationName=%ED%95%9C%EA%B0%95%EB%8C%80%EA%B5%90"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyHrfcoWaterLevelRequest injects API key and resolves station code path", async () => {
let calledUrls = [];
const result = await proxyHrfcoWaterLevelRequest({
stationName: "한강대교",
apiKey: "test-hrfco-key",
fetchImpl: async (url) => {
calledUrls.push(String(url));
if (String(url).endsWith("/waterlevel/info.json")) {
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", obsnm: "한강대교", agcnm: "한강홍수통제소" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
return new Response(
JSON.stringify({
content: [
{ wlobscd: "1018683", ymdhm: "202604051900", wl: "0.66", fw: "208.58" }
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
});
assert.equal(result.statusCode, 200);
assert.equal(JSON.parse(result.body).station_code, "1018683");
assert.match(calledUrls[0], /\/test-hrfco-key\/waterlevel\/info\.json$/);
assert.match(calledUrls[1], /\/test-hrfco-key\/waterlevel\/list\/10M\/1018683\.json$/);
});
const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<response>
<header><resultCode>000</resultCode><resultMsg>NORMAL SERVICE.</resultMsg></header>
<body>
<items>
<item>
<aptNm>래미안</aptNm><umdNm></umdNm><excluUseAr>84.99</excluUseAr>
<floor>12</floor><dealAmount> 245,000</dealAmount>
<dealYear>2024</dealYear><dealMonth>3</dealMonth><dealDay>15</dealDay>
<buildYear>2009</buildYear><dealingGbn></dealingGbn><cdealType></cdealType>
</item>
</items>
<totalCount>1</totalCount>
</body>
</response>`;
test("real estate region-code endpoint returns matching codes", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/region-code?q=%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.ok(body.results.length > 0);
assert.ok(body.results.some((r) => r.lawd_cd === "11680"));
assert.equal(body.proxy.cache.hit, false);
});
test("real estate region-code endpoint returns 400 for missing query", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/region-code"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("real estate transaction endpoint returns 503 without API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("real estate transaction endpoint returns 404 for invalid asset type", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/mansion/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 404);
});
test("real estate transaction endpoint returns 404 for commercial/rent", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/commercial/rent?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 404);
});
test("real estate transaction endpoint returns 400 for invalid lawd_cd", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=abc&deal_ymd=202403"
});
assert.equal(response.statusCode, 400);
});
test("real estate transaction endpoint fetches and returns parsed data", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response(SAMPLE_APT_TRADE_XML, {
status: 200,
headers: { "content-type": "text/xml;charset=UTF-8" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.items.length, 1);
assert.equal(body.items[0].name, "래미안");
assert.equal(body.items[0].price_10k, 245000);
assert.equal(body.query.asset_type, "apartment");
assert.equal(body.query.deal_type, "trade");
assert.equal(body.proxy.cache.hit, false);
});
test("real estate transaction endpoint caches successful responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(SAMPLE_APT_TRADE_XML, {
status: 200,
headers: { "content-type": "text/xml;charset=UTF-8" }
});
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "test-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/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
const second = await app.inject({
method: "GET",
url: "/v1/real-estate/apartment/trade?lawd_cd=11680&deal_ymd=202403"
});
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
});
test("health endpoint reports molitConfigured status", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.json().upstreams.molitConfigured, true);
});

View file

@ -0,0 +1,151 @@
# lck-analytics
`jerjangmin`님의 원본 [`lck-analytics` 스킬 팩](https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics)을 k-skill의 npm workspace / Changesets 배포 흐름에 맞춰 옮긴 LCK 전용 Node.js 클라이언트입니다.
Riot 공식 LoL Esports 데이터와 Oracle's Elixir 스타일 historical row / CSV를 함께 사용해 날짜별 LCK 경기 결과, 현재 순위, live turning point, 밴픽 matchup / synergy, patch meta, 팀 파워 레이팅을 계산합니다.
## Install
```bash
npm install lck-analytics
```
글로벌 skill 실행 예시는 아래를 기준으로 합니다.
```bash
npm install -g lck-analytics
```
## Origin / attribution
- Original skill + prototype package: <https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics>
- Original author: `jerjangmin`
- k-skill adaptation goal: same capability surface, but released through this repository's official npm/Changesets pipeline
## Official surfaces
- 일정/결과: `https://esports-api.lolesports.com/persisted/gw/getSchedule`
- 토너먼트 목록: `https://esports-api.lolesports.com/persisted/gw/getTournamentsForLeague`
- 순위: `https://esports-api.lolesports.com/persisted/gw/getStandings`
- 이벤트 상세: `https://esports-api.lolesports.com/persisted/gw/getEventDetails`
- 라이브 window: `https://feed.lolesports.com/livestats/v1/window/{gameId}`
- 라이브 details: `https://feed.lolesports.com/livestats/v1/details/{gameId}`
## Usage
```js
const {
buildHistoricalAnalytics,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getPatchMetaReport,
getStandings,
getTeamPowerRatings,
} = require("lck-analytics");
(async () => {
const results = await getMatchResults("2026-04-01", {
team: "한화",
});
const standings = await getStandings({
date: "2026-04-01",
team: "T1",
});
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
const historical = buildHistoricalAnalytics([
{
league: "LCK",
matchid: "sample-1",
date: "2026-04-01",
patch: "16.6.753.8272",
side: "blue",
teamname: "Hanwha Life Esports",
opponentteam: "T1",
playername: "HLE Zeus",
position: "top",
champion: "Aatrox",
opponentchampion: "Gnar",
result: "win",
gd15: 1200,
csd15: 18,
xpd15: 340,
drg: 100,
bn: 100,
blindpick: 0,
counterpick: 1,
},
]);
const patchMeta = getPatchMetaReport(historical, "16.6.753.8272");
const ratings = getTeamPowerRatings(historical);
const gameAnalysis = await getGameAnalysis("game-id", {
historicalDataset: historical,
liveWindowPayload: {/* optional cached payload */},
liveDetailsPayload: {/* optional cached payload */},
});
const matchAnalysis = await getMatchAnalysis("2026-04-01", {
historicalDataset: historical,
});
console.log(results.matches[0]);
console.log(standings.rows[0]);
console.log(summary);
console.log(patchMeta);
console.log(ratings[0]);
console.log(gameAnalysis.turningPoints);
console.log(matchAnalysis.matches[0]?.powerPreview);
})();
```
## API
### `getMatchResults(date, options)`
- `date`: `YYYY-MM-DD` 또는 `Date`
- `options.team`: 현재명 / 과거명 / 한글 / 영문 / 약칭 alias
- `options.maxPages`: 일정 페이지 탐색 상한, 기본값 `6`
- 기본적으로 `matches[*].games[*].live``matches[*].live` 에 인게임 실시간 요약을 채웁니다
- `options.includeLiveDetails === false` 이면 상세 live fetch를 생략합니다
### `getStandings(options)`
- `options.date`: `YYYY-MM-DD` 또는 `Date`
- `options.tournamentId`: 특정 토너먼트 강제 지정 가능
- `options.team`: 특정 팀만 현재 순위에서 필터링
### `getLckSummary(date, options)`
- 날짜 결과와 해당 시점 스플릿 순위를 한 번에 반환합니다
### `buildHistoricalAnalytics(input, options)`
- Oracle's Elixir 스타일 CSV 문자열 또는 row 배열로 historical 분석 데이터셋을 만듭니다
- 반환값에는 `teamPowerRatings`, `championStats`, `matchupStats`, `synergyStats`, `patchMeta` 가 포함됩니다
### `getGameAnalysis(gameId, options)`
- live window/details payload를 기반으로 timeline, turning points, draft edge, patch meta context를 계산합니다
### `getMatchAnalysis(date, options)`
- 날짜별 match 결과 위에 게임별 분석과 팀 파워 preview를 붙여 반환합니다
## Release note
이 패키지는 `packages/lck-analytics` workspace로 관리됩니다. `main` 에 머지되면 이 저장소의 Changesets 흐름이 **Version Packages PR** 을 만들고, 그 PR merge 후 npm publish가 실행됩니다.
## Notes
- 기본 Riot web header + 공개 API key fallback을 사용하지만, 안정성을 위해 `LOLESPORTS_API_KEY` 환경변수 override도 지원합니다
- turning point 분석은 공개 live snapshot 기반 heuristic 입니다
- `DN SOOPers`, `DN FREECS`, `광동 프릭스`, `Afreeca Freecs` 같은 리브랜딩 alias를 같은 canonical team으로 정규화합니다

View file

@ -0,0 +1,33 @@
{
"name": "lck-analytics",
"version": "0.1.0",
"description": "LCK match analytics and insights powered by Riot LoL Esports data",
"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",
"lck",
"league-of-legends",
"esports"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/teams.js && node --check src/oracle.js && node --check src/analytics.js && node --check test/index.test.js",
"test": "node --test"
},
"author": "k-skill maintainers (adapted from jerjangmin's original lck-analytics pack)"
}

View file

@ -0,0 +1,332 @@
const { normalizeLiveGameResponse } = require("./parse");
function normalizeLiveTimeline(windowPayload, detailsPayload, options = {}) {
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
const detailsByTimestamp = new Map(detailsFrames.map((frame) => [frame.rfc460Timestamp, frame]));
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
return windowFrames
.map((windowFrame) => normalizeTimelineFrame(windowPayload, windowFrame, detailsByTimestamp.get(windowFrame.rfc460Timestamp), {
...options,
firstTimestamp,
}))
.filter(Boolean);
}
function normalizeTimelineFrame(windowPayload, windowFrame, detailsFrame, options = {}) {
const live = normalizeLiveGameResponse({
esportsGameId: windowPayload?.esportsGameId,
esportsMatchId: windowPayload?.esportsMatchId,
gameMetadata: windowPayload?.gameMetadata,
frames: [windowFrame],
}, {
frames: detailsFrame ? [detailsFrame] : [],
}, options);
if (!live) {
return null;
}
return {
timestamp: windowFrame.rfc460Timestamp,
gameState: windowFrame.gameState || null,
durationSeconds: options.firstTimestamp
? Math.max(0, Math.round((new Date(windowFrame.rfc460Timestamp) - new Date(options.firstTimestamp)) / 1000))
: live.durationSeconds,
blueTeam: live.blueTeam,
redTeam: live.redTeam,
goldDiff: live.goldDiff,
killDiff: live.killDiff,
objectiveScore: computeObjectiveScore(live.blueTeam) - computeObjectiveScore(live.redTeam),
};
}
function analyzeTurningPoints(timeline, options = {}) {
const thresholdGoldSwing = options.thresholdGoldSwing || 1800;
const thresholdObjectiveSwing = options.thresholdObjectiveSwing || 2;
const candidates = [];
for (let index = 1; index < timeline.length; index += 1) {
const previous = timeline[index - 1];
const current = timeline[index];
const goldSwing = Math.abs((current.goldDiff ?? 0) - (previous.goldDiff ?? 0));
const killSwing = Math.abs((current.killDiff ?? 0) - (previous.killDiff ?? 0));
const objectiveSwing = Math.abs((current.objectiveScore ?? 0) - (previous.objectiveScore ?? 0));
const leadFlip = Math.sign(current.goldDiff || 0) !== 0
&& Math.sign(previous.goldDiff || 0) !== 0
&& Math.sign(current.goldDiff || 0) !== Math.sign(previous.goldDiff || 0);
if (!leadFlip && goldSwing < thresholdGoldSwing && objectiveSwing < thresholdObjectiveSwing && killSwing < 3) {
continue;
}
const favoredSide = resolveFavoredSide(current.goldDiff);
candidates.push({
timestamp: current.timestamp,
durationSeconds: current.durationSeconds,
goldSwing,
killSwing,
objectiveSwing,
favoredSide,
swingScore: round((goldSwing / 300) + (killSwing * 3) + (objectiveSwing * 6) + (leadFlip ? 10 : 0), 2),
reason: summarizeTurningPoint(previous, current, { goldSwing, killSwing, objectiveSwing, leadFlip, favoredSide }),
});
}
return candidates.sort((left, right) => right.swingScore - left.swingScore).slice(0, options.limit || 3);
}
function summarizeTurningPoint(previous, current, context) {
const pieces = [];
if (context.leadFlip) {
pieces.push("골드 리드가 뒤집혔습니다");
}
if (context.goldSwing >= 1800) {
pieces.push(`골드 차이가 ${formatSigned(current.goldDiff)}로 크게 움직였습니다`);
}
if (context.killSwing >= 3) {
pieces.push(`교전으로 킬 차이가 ${formatSigned(current.killDiff)}가 됐습니다`);
}
if (context.objectiveSwing >= 2) {
pieces.push("주요 오브젝트 격차가 벌어졌습니다");
}
if (pieces.length === 0) {
pieces.push("중요한 흐름 변화가 감지됐습니다");
}
const side = context.favoredSide === "blue" ? "블루" : context.favoredSide === "red" ? "레드" : null;
return side ? `${side} 진영 기준 turning point: ${pieces.join(", ")}` : pieces.join(", ");
}
function analyzeDraft(game, historicalDataset, options = {}) {
const patch = options.patch || game?.live?.patchVersion || null;
const blueParticipants = game?.live?.blueTeam?.participants || [];
const redParticipants = game?.live?.redTeam?.participants || [];
const matchupStats = historicalDataset?.matchupStats || [];
const synergyStats = historicalDataset?.synergyStats || [];
const roleMatchups = blueParticipants.map((blueParticipant) => {
const redParticipant = redParticipants.find((candidate) => candidate.role === blueParticipant.role);
if (!redParticipant) {
return null;
}
const blueEdge = findMatchup(matchupStats, patch, blueParticipant.role, blueParticipant.championId, redParticipant.championId);
const redEdge = findMatchup(matchupStats, patch, redParticipant.role, redParticipant.championId, blueParticipant.championId);
const favoredSide = pickFavoredSide(blueEdge, redEdge);
return {
role: blueParticipant.role,
blueChampion: blueParticipant.championId,
redChampion: redParticipant.championId,
favoredSide,
blueSample: blueEdge?.games || 0,
redSample: redEdge?.games || 0,
summary: summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant),
};
}).filter(Boolean);
const blueSynergy = scoreSynergy(blueParticipants, synergyStats, patch);
const redSynergy = scoreSynergy(redParticipants, synergyStats, patch);
return {
patch,
roleMatchups,
blueSynergy,
redSynergy,
overallEdge: pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy),
};
}
function scoreSynergy(participants, synergyStats, patch) {
const champions = participants.map((participant) => participant.championId).filter(Boolean);
const pairs = [];
for (let index = 0; index < champions.length; index += 1) {
for (let inner = index + 1; inner < champions.length; inner += 1) {
const championA = champions[index];
const championB = champions[inner];
const stat = synergyStats.find((entry) => (
(!patch || entry.patch === patch)
&& ((entry.championA === championA && entry.championB === championB)
|| (entry.championA === championB && entry.championB === championA))
));
if (stat) {
pairs.push(stat);
}
}
}
return {
samplePairs: pairs.length,
avgWinRate: round(average(pairs.map((entry) => entry.winRate)), 2),
topPairs: pairs.sort((left, right) => right.games - left.games).slice(0, 3),
};
}
function summarizeMetaForGame(game, historicalDataset, options = {}) {
const patch = options.patch || game?.live?.patchVersion || null;
const patchMeta = (historicalDataset?.patchMeta || []).find((entry) => !patch || entry.patch === patch) || null;
return {
patch,
topPicks: patchMeta?.topPicks || [],
risers: patchMeta?.risers || [],
};
}
function buildGameAnalysis(game, historicalDataset, options = {}) {
const timeline = normalizeLiveTimeline(options.liveWindowPayload, options.liveDetailsPayload, {
gameId: game.id,
matchId: options.matchId,
});
const currentLive = timeline.at(-1) ? {
gameId: game.id,
patchVersion: options.liveWindowPayload?.gameMetadata?.patchVersion || null,
durationSeconds: timeline.at(-1).durationSeconds,
blueTeam: timeline.at(-1).blueTeam,
redTeam: timeline.at(-1).redTeam,
goldDiff: timeline.at(-1).goldDiff,
killDiff: timeline.at(-1).killDiff,
} : game.live || null;
const enrichedGame = {
...game,
live: currentLive,
};
return {
gameId: game.id,
number: game.number,
state: game.state,
patch: currentLive?.patchVersion || null,
current: currentLive,
timeline,
turningPoints: analyzeTurningPoints(timeline, options.turningPointOptions),
draft: analyzeDraft(enrichedGame, historicalDataset, {
patch: currentLive?.patchVersion || null,
}),
meta: summarizeMetaForGame(enrichedGame, historicalDataset, {
patch: currentLive?.patchVersion || null,
}),
};
}
function buildTeamPreview(teamId, historicalDataset) {
const ratings = historicalDataset?.teamPowerRatings || [];
return ratings.find((entry) => entry.teamId === teamId) || null;
}
function compareTeams(teamAId, teamBId, historicalDataset) {
const left = buildTeamPreview(teamAId, historicalDataset);
const right = buildTeamPreview(teamBId, historicalDataset);
return {
teamA: left,
teamB: right,
favoredTeamId: !left || !right ? left?.teamId || right?.teamId || null : left.powerScore >= right.powerScore ? left.teamId : right.teamId,
powerGap: left && right ? round(Math.abs(left.powerScore - right.powerScore), 2) : null,
};
}
function getPatchMetaSummary(historicalDataset, patch) {
return (historicalDataset?.patchMeta || []).find((entry) => entry.patch === patch) || null;
}
function findMatchup(matchupStats, patch, role, champion, opponentChampion) {
return matchupStats.find((entry) => (
(!patch || entry.patch === patch)
&& entry.position === role
&& entry.champion === champion
&& entry.opponentChampion === opponentChampion
)) || null;
}
function pickFavoredSide(blueEdge, redEdge) {
const blueScore = scoreMatchup(blueEdge);
const redScore = scoreMatchup(redEdge);
if (blueScore === redScore) {
return null;
}
return blueScore > redScore ? "blue" : "red";
}
function scoreMatchup(edge) {
if (!edge) {
return 0;
}
return (edge.winRate || 0) + Math.min(edge.games || 0, 10);
}
function summarizeMatchup(blueEdge, redEdge, blueParticipant, redParticipant) {
if (!blueEdge && !redEdge) {
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업 표본이 부족합니다.`;
}
const favored = pickFavoredSide(blueEdge, redEdge);
if (favored === "blue") {
return `${blueParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
}
if (favored === "red") {
return `${redParticipant.championId} 쪽이 유리한 매치업으로 보입니다.`;
}
return `${blueParticipant.championId} vs ${redParticipant.championId} 매치업은 팽팽합니다.`;
}
function pickOverallDraftEdge(roleMatchups, blueSynergy, redSynergy) {
const blueWins = roleMatchups.filter((entry) => entry.favoredSide === "blue").length;
const redWins = roleMatchups.filter((entry) => entry.favoredSide === "red").length;
const blueScore = blueWins + ((blueSynergy.avgWinRate || 50) / 100);
const redScore = redWins + ((redSynergy.avgWinRate || 50) / 100);
if (blueScore === redScore) {
return null;
}
return blueScore > redScore ? "blue" : "red";
}
function computeObjectiveScore(team) {
if (!team) {
return 0;
}
return (team.towers || 0)
+ ((team.inhibitors || 0) * 1.5)
+ ((team.barons || 0) * 3)
+ ((team.dragonCount || 0) * 1.2);
}
function resolveFavoredSide(goldDiff) {
if (!Number.isFinite(goldDiff) || goldDiff === 0) {
return null;
}
return goldDiff > 0 ? "blue" : "red";
}
function formatSigned(value) {
if (!Number.isFinite(value)) {
return "0";
}
return `${value > 0 ? "+" : ""}${Math.round(value)}`;
}
function average(values) {
const filtered = values.filter((value) => Number.isFinite(value));
if (filtered.length === 0) {
return null;
}
return filtered.reduce((sum, value) => sum + value, 0) / filtered.length;
}
function round(value, digits = 2) {
if (!Number.isFinite(value)) {
return null;
}
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
module.exports = {
analyzeDraft,
analyzeTurningPoints,
buildGameAnalysis,
buildTeamPreview,
compareTeams,
getPatchMetaSummary,
normalizeLiveTimeline,
};

View file

@ -0,0 +1,24 @@
const LCK_LEAGUE_ID = "98767991310872058";
const DEFAULT_LOLESPORTS_API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z";
const LOLESPORTS_API_BASE_URL = "https://esports-api.lolesports.com/persisted/gw";
const LIVE_STATS_BASE_URL = "https://feed.lolesports.com/livestats/v1";
const DEFAULT_HEADERS = {
accept: "application/json",
"accept-language": "en-US,en;q=0.9,ko-KR;q=0.8,ko;q=0.7",
"user-agent": "k-skill/lck-analytics",
};
const STATUS_MAP = {
completed: { state: "finished", label: "종료", finished: true },
inProgress: { state: "live", label: "진행 중", finished: false },
unstarted: { state: "scheduled", label: "예정", finished: false },
};
module.exports = {
DEFAULT_HEADERS,
DEFAULT_LOLESPORTS_API_KEY,
LCK_LEAGUE_ID,
LIVE_STATS_BASE_URL,
LOLESPORTS_API_BASE_URL,
STATUS_MAP,
};

View file

@ -0,0 +1,461 @@
const {
DEFAULT_HEADERS,
DEFAULT_LOLESPORTS_API_KEY,
LCK_LEAGUE_ID,
LIVE_STATS_BASE_URL,
LOLESPORTS_API_BASE_URL,
} = require("./constants");
const {
normalizeDateInput,
normalizeEventDetailsResponse,
normalizeLiveGameResponse,
normalizeScheduleResponse,
normalizeStandingsResponse,
normalizeTournamentList,
resolveTournamentForDate,
} = require("./parse");
const {
buildGameAnalysis,
compareTeams,
getPatchMetaSummary,
} = require("./analytics");
const {
buildHistoricalDataset,
parseOracleCsv,
} = require("./oracle");
async function requestJson(path, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const url = new URL(`${LOLESPORTS_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(options.query || {})) {
if (value !== null && value !== undefined && value !== "") {
url.searchParams.set(key, String(value));
}
}
const response = await fetchImpl(url, {
method: "GET",
headers: {
...DEFAULT_HEADERS,
"x-api-key": options.apiKey || process.env.LOLESPORTS_API_KEY || DEFAULT_LOLESPORTS_API_KEY,
...(options.headers || {}),
},
signal: options.signal,
});
if (!response.ok) {
throw new Error(`LoL Esports request failed with ${response.status} for ${url}`);
}
return response.json();
}
async function fetchSchedulePage(options = {}) {
return requestJson("getSchedule", {
query: {
hl: options.hl || "en-US",
leagueId: options.leagueId || LCK_LEAGUE_ID,
pageToken: options.pageToken,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchTournaments(options = {}) {
return requestJson("getTournamentsForLeague", {
query: {
hl: options.hl || "en-US",
leagueId: options.leagueId || LCK_LEAGUE_ID,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchStandings(options = {}) {
return requestJson("getStandings", {
query: {
hl: options.hl || "en-US",
tournamentId: options.tournamentId,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function fetchEventDetails(options = {}) {
return requestJson("getEventDetails", {
query: {
hl: options.hl || "en-US",
id: options.eventId,
},
fetchImpl: options.fetchImpl,
apiKey: options.apiKey,
signal: options.signal,
});
}
async function requestLiveJson(kind, gameId, options = {}) {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const url = new URL(`${LIVE_STATS_BASE_URL}/${kind}/${gameId}`);
const response = await fetchImpl(url, {
method: "GET",
headers: {
accept: "application/json",
"user-agent": "k-skill/lck-analytics",
...(options.headers || {}),
},
signal: options.signal,
});
if (response.status === 204) {
return null;
}
if (!response.ok) {
throw new Error(`LoL Esports live stats request failed with ${response.status} for ${url}`);
}
return response.json();
}
async function fetchLiveWindow(options = {}) {
return requestLiveJson("window", options.gameId, options);
}
async function fetchLiveDetails(options = {}) {
return requestLiveJson("details", options.gameId, options);
}
async function getMatchResults(date, options = {}) {
const requestedDate = normalizeDateInput(date).isoDate;
const pages = [];
let payload = options.schedulePayload || await fetchSchedulePage(options);
pages.push(payload);
const maxPages = Number(options.maxPages || 6);
let direction = resolveSearchDirection(payload, requestedDate);
let pageCount = 1;
while (direction && pageCount < maxPages) {
const pageToken = payload?.data?.schedule?.pages?.[direction];
if (!pageToken) {
break;
}
payload = await fetchSchedulePage({
...options,
pageToken,
});
pages.push(payload);
pageCount += 1;
direction = resolveSearchDirection(payload, requestedDate);
}
const mergedPayload = mergeSchedulePages(pages);
const result = normalizeScheduleResponse(mergedPayload, {
date: requestedDate,
team: options.team,
});
const matches = options.includeLiveDetails === false
? result.matches
: await enrichMatchesWithLiveDetails(result.matches, options);
return {
...result,
matches,
pagesExamined: pages.length,
};
}
async function getStandings(options = {}) {
const tournamentsPayload = options.tournamentsPayload || await fetchTournaments(options);
const tournaments = normalizeTournamentList(tournamentsPayload);
const tournament = options.tournamentId
? tournaments.find((candidate) => candidate.id === String(options.tournamentId)) || { id: String(options.tournamentId) }
: resolveTournamentForDate(tournaments, options.date || new Date());
if (!tournament?.id) {
return {
tournamentId: null,
tournamentName: null,
stage: null,
sectionName: null,
filteredTeam: options.team ? { input: options.team, canonicalId: null, normalized: options.team } : null,
rows: [],
};
}
const standingsPayload = options.standingsPayload || await fetchStandings({
...options,
tournamentId: tournament.id,
});
return normalizeStandingsResponse(standingsPayload, {
tournament,
team: options.team,
});
}
async function enrichMatchesWithLiveDetails(matches, options = {}) {
return Promise.all(matches.map(async (match) => {
const eventDetailsPayload = options.eventDetailsByMatchId?.[match.eventId]
|| options.eventDetailsByMatchId?.[match.matchId]
|| await fetchEventDetails({
...options,
eventId: match.eventId || match.matchId,
});
const eventDetails = normalizeEventDetailsResponse(eventDetailsPayload);
const games = await Promise.all(eventDetails.games.map(async (game) => {
if (game.state !== "inProgress") {
return {
...game,
live: null,
};
}
const liveWindowPayload = options.liveWindowByGameId?.[game.id] !== undefined
? options.liveWindowByGameId[game.id]
: await fetchLiveWindow({ ...options, gameId: game.id });
const liveDetailsPayload = options.liveDetailsByGameId?.[game.id] !== undefined
? options.liveDetailsByGameId[game.id]
: await fetchLiveDetails({ ...options, gameId: game.id });
return {
...game,
live: normalizeLiveGameResponse(liveWindowPayload, liveDetailsPayload, {
gameId: game.id,
matchId: match.matchId,
}),
};
}));
return {
...match,
streams: eventDetails.streams,
games,
live: match.status.state === "live" ? summarizeMatchLive(games) : null,
};
}));
}
async function getLckSummary(date, options = {}) {
const matches = options.matchesResponse || await getMatchResults(date, options);
const summary = {
queryDate: matches.queryDate,
filteredTeam: matches.filteredTeam,
matches: matches.matches,
};
if (options.includeStandings !== false) {
summary.standings = await getStandings({
...options,
date,
team: options.team,
tournamentsPayload: options.tournamentsPayload,
standingsPayload: options.standingsPayload,
});
}
return summary;
}
function buildHistoricalAnalytics(input, options = {}) {
const rows = typeof input === "string" ? parseOracleCsv(input) : input;
return buildHistoricalDataset(Array.isArray(rows) ? rows : [], options);
}
async function getGameAnalysis(gameId, options = {}) {
const historical = options.historicalDataset
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
const liveWindowPayload = options.liveWindowPayload !== undefined
? options.liveWindowPayload
: await fetchLiveWindow({ ...options, gameId });
const liveDetailsPayload = options.liveDetailsPayload !== undefined
? options.liveDetailsPayload
: await fetchLiveDetails({ ...options, gameId });
const game = options.game || {
id: gameId,
number: options.number || null,
state: options.state || (liveWindowPayload?.frames?.length ? "inProgress" : null),
live: null,
};
return buildGameAnalysis(game, historical, {
matchId: options.matchId,
liveWindowPayload,
liveDetailsPayload,
turningPointOptions: options.turningPointOptions,
});
}
async function getMatchAnalysis(date, options = {}) {
const matchesResponse = options.matchesResponse || await getMatchResults(date, options);
const historical = options.historicalDataset
|| buildHistoricalAnalytics(options.oracleCsv || options.historicalRows || [], options);
const matches = await Promise.all(matchesResponse.matches.map(async (match) => {
const baseMatch = options.includeLiveDetails === false || match.games ? match : (await enrichMatchesWithLiveDetails([match], options))[0];
const analyses = await Promise.all((baseMatch.games || []).map(async (game) => {
if (!game.live && options.liveWindowByGameId?.[game.id] === undefined && game.state !== "inProgress") {
return {
gameId: game.id,
number: game.number,
state: game.state,
patch: null,
current: null,
timeline: [],
turningPoints: [],
draft: null,
meta: null,
};
}
return getGameAnalysis(game.id, {
...options,
game,
matchId: baseMatch.matchId,
historicalDataset: historical,
liveWindowPayload: options.liveWindowByGameId?.[game.id],
liveDetailsPayload: options.liveDetailsByGameId?.[game.id],
});
}));
return {
...baseMatch,
analyses,
powerPreview: compareTeams(baseMatch.team1?.canonicalId, baseMatch.team2?.canonicalId, historical),
};
}));
return {
queryDate: matchesResponse.queryDate,
filteredTeam: matchesResponse.filteredTeam,
matches,
};
}
function getTeamPowerRatings(input, options = {}) {
const historical = input?.teamPowerRatings ? input : buildHistoricalAnalytics(input, options);
return historical.teamPowerRatings;
}
function getPatchMetaReport(input, patch, options = {}) {
const historical = input?.patchMeta ? input : buildHistoricalAnalytics(input, options);
return getPatchMetaSummary(historical, patch);
}
function mergeSchedulePages(pages) {
const merged = [];
const seen = new Set();
for (const payload of pages) {
for (const event of payload?.data?.schedule?.events || []) {
const key = event?.match?.id || event?.id;
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
merged.push(event);
}
}
return {
data: {
schedule: {
events: merged,
pages: pages.at(-1)?.data?.schedule?.pages || { older: null, newer: null },
},
},
};
}
function summarizeMatchLive(games) {
const currentGame = games.find((game) => game.state === "inProgress" && game.live)
|| games.find((game) => game.live);
if (!currentGame) {
return null;
}
return {
currentGameNumber: currentGame.number,
currentGameState: currentGame.state,
gameId: currentGame.id,
gameState: currentGame.live?.gameState || null,
updatedAt: currentGame.live?.updatedAt || null,
durationSeconds: currentGame.live?.durationSeconds ?? null,
blueTeam: currentGame.live?.blueTeam || null,
redTeam: currentGame.live?.redTeam || null,
goldDiff: currentGame.live?.goldDiff ?? null,
killDiff: currentGame.live?.killDiff ?? null,
};
}
function resolveSearchDirection(payload, requestedDate) {
const eventDates = (payload?.data?.schedule?.events || [])
.map((event) => event?.startTime)
.filter(Boolean)
.map((startTime) => new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(startTime)));
if (eventDates.length === 0) {
return null;
}
const sorted = [...eventDates].sort();
const minDate = sorted[0];
const maxDate = sorted.at(-1);
if (requestedDate < minDate) {
return "older";
}
if (requestedDate > maxDate) {
return "newer";
}
return null;
}
module.exports = {
buildHistoricalAnalytics,
enrichMatchesWithLiveDetails,
fetchEventDetails,
fetchLiveDetails,
fetchLiveWindow,
fetchSchedulePage,
fetchStandings,
fetchTournaments,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getPatchMetaReport,
getStandings,
getTeamPowerRatings,
mergeSchedulePages,
parseOracleCsv,
requestJson,
requestLiveJson,
resolveSearchDirection,
summarizeMatchLive,
};

View file

@ -0,0 +1,425 @@
const { resolveTeamQuery } = require("./teams");
function parseOracleCsv(csvText) {
const text = String(csvText || "").trim();
if (!text) {
return [];
}
const rows = parseCsv(text);
if (rows.length === 0) {
return [];
}
const [headerRow, ...bodyRows] = rows;
const headers = headerRow.map((value) => String(value || "").trim());
return bodyRows
.filter((row) => row.some((cell) => String(cell || "").trim() !== ""))
.map((row) => Object.fromEntries(headers.map((header, index) => [header, normalizeCell(row[index])] )));
}
function parseCsv(text) {
const rows = [];
let row = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (inQuotes && next === '"') {
cell += '"';
index += 1;
} else {
inQuotes = !inQuotes;
}
continue;
}
if (!inQuotes && char === ',') {
row.push(cell);
cell = "";
continue;
}
if (!inQuotes && (char === '\n' || char === '\r')) {
if (char === '\r' && next === '\n') {
index += 1;
}
row.push(cell);
rows.push(row);
row = [];
cell = "";
continue;
}
cell += char;
}
row.push(cell);
rows.push(row);
return rows;
}
function normalizeCell(value) {
const trimmed = String(value ?? "").trim();
if (trimmed === "") {
return null;
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const numeric = Number(trimmed);
if (Number.isFinite(numeric)) {
return numeric;
}
}
return trimmed;
}
function normalizeOracleGameRows(rows, options = {}) {
const filteredLeague = options.league || "LCK";
const normalized = rows
.filter((row) => !filteredLeague || String(row.league || row.League || "").toUpperCase() === String(filteredLeague).toUpperCase())
.map((row) => {
const teamQuery = resolveTeamQuery(row.teamname || row.Team || row.team || row.team_name);
const opponentQuery = resolveTeamQuery(row.opponentteam || row.opponent || row.Opponent || row.opponent_team);
const patch = String(row.patch || row.gameversion || row.gameVersion || "");
const champion = row.champion || row.Champion || null;
const position = String(row.position || row.Pos || row.pos || "").toLowerCase() || null;
const result = normalizeResult(row.result ?? row.Result);
return {
matchId: String(row.matchid || row.MatchId || row.match_id || row.gameid || row.GameId || `${row.date || row.Date}-${teamQuery.currentName}-${champion}`),
date: String(row.date || row.Date || ""),
patch,
side: normalizeSide(row.side || row.Side),
team: {
input: teamQuery.input,
canonicalId: teamQuery.canonicalId,
name: teamQuery.currentName,
},
opponent: {
input: opponentQuery.input,
canonicalId: opponentQuery.canonicalId,
name: opponentQuery.currentName,
},
playerName: row.playername || row.Player || row.player || null,
position,
champion,
opponentChampion: row.opponentchampion || row.opponent_champion || row.OpponentChampion || null,
result,
goldDiffAt15: toNumber(row.gd15 ?? row.GD15 ?? row.goldDiffAt15),
csDiffAt15: toNumber(row.csd15 ?? row.CSD15 ?? row.csDiffAt15),
xpDiffAt15: toNumber(row.xpd15 ?? row.XPD15 ?? row.xpDiffAt15),
firstBloodRate: toNumber(row.fb ?? row["FB%"] ?? row.firstBloodRate),
dragonControlRate: toNumber(row.drg ?? row["DRG%"] ?? row.dragonControlRate),
baronControlRate: toNumber(row.bn ?? row["BN%"] ?? row.baronControlRate),
towerControlRate: toNumber(row.ft ?? row["FT%"] ?? row.towerControlRate),
kills: toNumber(row.kills ?? row.K),
deaths: toNumber(row.deaths ?? row.D),
assists: toNumber(row.assists ?? row.A),
gamesPlayed: toNumber(row.gamesplayed ?? row.GP) ?? 1,
blindPick: toBooleanLike(row.blindpick ?? row["BLND%"] ?? row.blindPick),
counterPick: toBooleanLike(row.counterpick ?? row["CTR%"] ?? row.counterPick),
};
})
.filter((row) => row.team.canonicalId && row.opponent.canonicalId && row.position && row.champion);
return normalized;
}
function buildHistoricalDataset(rows, options = {}) {
const normalizedRows = normalizeOracleGameRows(rows, options);
return {
rows: normalizedRows,
teamPowerRatings: buildTeamPowerRatings(normalizedRows),
championStats: buildChampionPatchStats(normalizedRows),
matchupStats: buildChampionMatchups(normalizedRows),
synergyStats: buildChampionSynergies(normalizedRows),
patchMeta: buildPatchMetaSummaries(normalizedRows),
};
}
function buildTeamPowerRatings(rows) {
const grouped = new Map();
for (const row of rows) {
const key = row.team.canonicalId;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([teamId, teamRows]) => {
const wins = teamRows.filter((row) => row.result === "win").length;
const losses = teamRows.filter((row) => row.result === "loss").length;
const games = teamRows.length;
const recentRows = teamRows.slice(-5);
const recentWins = recentRows.filter((row) => row.result === "win").length;
const avgGold15 = average(teamRows.map((row) => row.goldDiffAt15));
const avgDragon = average(teamRows.map((row) => row.dragonControlRate));
const avgBaron = average(teamRows.map((row) => row.baronControlRate));
const weightedScore = round(
((wins / Math.max(games, 1)) * 60)
+ (normalizeScale(avgGold15, -3000, 3000) * 20)
+ (normalizeScale(avgDragon, 0, 100) * 10)
+ (normalizeScale(avgBaron, 0, 100) * 10),
2,
);
return {
teamId,
games,
wins,
losses,
recentWins,
recentGames: recentRows.length,
avgGoldDiffAt15: avgGold15,
avgDragonControlRate: avgDragon,
avgBaronControlRate: avgBaron,
powerScore: weightedScore,
tier: weightedScore >= 75 ? "elite" : weightedScore >= 60 ? "strong" : weightedScore >= 45 ? "average" : "developing",
};
}).sort((left, right) => right.powerScore - left.powerScore);
}
function buildChampionPatchStats(rows) {
const grouped = new Map();
for (const row of rows) {
const key = `${row.patch}::${row.position}::${row.champion}`;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([key, championRows]) => {
const [patch, position, champion] = key.split("::");
return {
patch,
position,
champion,
games: championRows.length,
wins: championRows.filter((row) => row.result === "win").length,
winRate: round(rate(championRows.filter((row) => row.result === "win").length, championRows.length), 2),
avgGoldDiffAt15: average(championRows.map((row) => row.goldDiffAt15)),
avgCsDiffAt15: average(championRows.map((row) => row.csDiffAt15)),
avgXpDiffAt15: average(championRows.map((row) => row.xpDiffAt15)),
blindPickRate: round(rate(championRows.filter((row) => row.blindPick === true).length, championRows.length), 2),
counterPickRate: round(rate(championRows.filter((row) => row.counterPick === true).length, championRows.length), 2),
};
}).sort((left, right) => {
if (left.patch !== right.patch) {
return left.patch.localeCompare(right.patch);
}
return right.games - left.games;
});
}
function buildChampionMatchups(rows) {
const grouped = new Map();
for (const row of rows) {
if (!row.opponentChampion) {
continue;
}
const key = `${row.patch}::${row.position}::${row.champion}::${row.opponentChampion}`;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key).push(row);
}
return [...grouped.entries()].map(([key, matchupRows]) => {
const [patch, position, champion, opponentChampion] = key.split("::");
const wins = matchupRows.filter((row) => row.result === "win").length;
return {
patch,
position,
champion,
opponentChampion,
games: matchupRows.length,
wins,
winRate: round(rate(wins, matchupRows.length), 2),
avgGoldDiffAt15: average(matchupRows.map((row) => row.goldDiffAt15)),
avgCsDiffAt15: average(matchupRows.map((row) => row.csDiffAt15)),
counterPickRate: round(rate(matchupRows.filter((row) => row.counterPick === true).length, matchupRows.length), 2),
};
}).sort((left, right) => right.games - left.games);
}
function buildChampionSynergies(rows) {
const games = new Map();
for (const row of rows) {
const key = `${row.matchId}::${row.team.canonicalId}`;
if (!games.has(key)) {
games.set(key, []);
}
games.get(key).push(row);
}
const synergy = new Map();
for (const gameRows of games.values()) {
for (let index = 0; index < gameRows.length; index += 1) {
for (let inner = index + 1; inner < gameRows.length; inner += 1) {
const left = gameRows[index];
const right = gameRows[inner];
const pair = [left.champion, right.champion].sort();
const key = `${left.patch}::${pair[0]}::${pair[1]}`;
if (!synergy.has(key)) {
synergy.set(key, []);
}
synergy.get(key).push(left.result === "win" ? 1 : 0);
}
}
}
return [...synergy.entries()].map(([key, results]) => {
const [patch, championA, championB] = key.split("::");
const wins = results.reduce((sum, value) => sum + value, 0);
return {
patch,
championA,
championB,
games: results.length,
wins,
winRate: round(rate(wins, results.length), 2),
};
}).sort((left, right) => right.games - left.games);
}
function buildPatchMetaSummaries(rows) {
const championStats = buildChampionPatchStats(rows);
const byPatch = new Map();
for (const stat of championStats) {
if (!byPatch.has(stat.patch)) {
byPatch.set(stat.patch, []);
}
byPatch.get(stat.patch).push(stat);
}
return [...byPatch.entries()].map(([patch, stats], index, all) => {
const sorted = [...stats].sort((left, right) => right.games - left.games);
const topPicks = sorted.slice(0, 5).map((entry) => ({
champion: entry.champion,
position: entry.position,
games: entry.games,
winRate: entry.winRate,
}));
const previousPatch = all[index - 1]?.[0] || null;
const previousStats = previousPatch ? byPatch.get(previousPatch) || [] : [];
const risers = topPicks.map((pick) => {
const previous = previousStats.find((entry) => entry.champion === pick.champion && entry.position === pick.position);
return {
...pick,
gameDelta: pick.games - (previous?.games || 0),
};
}).sort((left, right) => right.gameDelta - left.gameDelta);
return {
patch,
topPicks,
risers: risers.slice(0, 3),
};
}).sort((left, right) => left.patch.localeCompare(right.patch));
}
function normalizeResult(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["1", "win", "w", "true"].includes(normalized)) {
return "win";
}
if (["0", "loss", "lose", "l", "false"].includes(normalized)) {
return "loss";
}
return null;
}
function normalizeSide(value) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["blue", "b", "100"].includes(normalized)) {
return "blue";
}
if (["red", "r", "200"].includes(normalized)) {
return "red";
}
return null;
}
function toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function toBooleanLike(value) {
if (typeof value === "boolean") {
return value;
}
const numeric = toNumber(value);
if (numeric === null) {
return null;
}
if (numeric === 0) {
return false;
}
if (numeric === 1) {
return true;
}
return numeric >= 50;
}
function average(values) {
const filtered = values.filter((value) => Number.isFinite(value));
if (filtered.length === 0) {
return null;
}
return round(filtered.reduce((sum, value) => sum + value, 0) / filtered.length, 2);
}
function rate(numerator, denominator) {
if (!denominator) {
return 0;
}
return (numerator / denominator) * 100;
}
function normalizeScale(value, min, max) {
if (!Number.isFinite(value)) {
return 0;
}
if (max <= min) {
return 0;
}
const clamped = Math.min(max, Math.max(min, value));
return (clamped - min) / (max - min);
}
function round(value, digits = 2) {
if (!Number.isFinite(value)) {
return null;
}
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}
module.exports = {
buildChampionMatchups,
buildChampionPatchStats,
buildChampionSynergies,
buildHistoricalDataset,
buildPatchMetaSummaries,
buildTeamPowerRatings,
normalizeOracleGameRows,
parseOracleCsv,
};

View file

@ -0,0 +1,444 @@
const { STATUS_MAP } = require("./constants");
const {
resolveTeamPayload,
resolveTeamQuery,
stripAliasTokens,
} = require("./teams");
function normalizeDateInput(value) {
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const parts = formatter.formatToParts(value).reduce((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return {
year: parts.year,
month: parts.month,
day: parts.day,
isoDate: `${parts.year}-${parts.month}-${parts.day}`,
};
}
const match = String(value || "").trim().match(/^(\d{4})[-.](\d{2})[-.](\d{2})$/);
if (!match) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
const [year, month, day] = [match[1], match[2], match[3]];
const candidate = new Date(`${year}-${month}-${day}T00:00:00+09:00`);
if (Number.isNaN(candidate.getTime())) {
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
}
return {
year,
month,
day,
isoDate: `${year}-${month}-${day}`,
};
}
function eventToKoreaDateTime(startTime) {
const date = new Date(startTime);
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(date).reduce((acc, part) => {
if (part.type !== "literal") {
acc[part.type] = part.value;
}
return acc;
}, {});
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
kickOff: `${parts.hour}:${parts.minute}`,
};
}
function normalizeMatchStatus(state) {
const mapped = STATUS_MAP[state] || {
state: String(state || "unknown"),
label: String(state || "알 수 없음"),
finished: false,
};
return {
code: state || "unknown",
state: mapped.state,
label: mapped.label,
finished: mapped.finished,
};
}
function normalizeScheduleResponse(payload, options = {}) {
const schedule = payload?.data?.schedule || payload?.schedule || payload || {};
const requestedDate = normalizeDateInput(options.date);
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
const events = Array.isArray(schedule.events) ? schedule.events : [];
const matches = events
.filter((event) => event?.league?.slug === "lck")
.map(normalizeEventMatch)
.filter((match) => match.date === requestedDate.isoDate)
.filter((match) => !requestedTeam || matchIncludesRequestedTeam(match, requestedTeam))
.sort(compareMatches);
return {
queryDate: requestedDate.isoDate,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
canonicalId: requestedTeam.canonicalId,
normalized: requestedTeam.currentName,
}
: null,
matches,
pageInfo: {
older: schedule.pages?.older || null,
newer: schedule.pages?.newer || null,
},
};
}
function normalizeEventMatch(event) {
const teams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
const [team1, team2] = teams;
const koreaTime = eventToKoreaDateTime(event.startTime);
const status = normalizeMatchStatus(event.state || event?.match?.state);
const score = {
team1: normalizeNumber(team1?.result?.gameWins ?? event?.match?.teams?.[0]?.result?.gameWins),
team2: normalizeNumber(team2?.result?.gameWins ?? event?.match?.teams?.[1]?.result?.gameWins),
};
return {
matchId: event?.match?.id || event?.id || null,
eventId: event?.id || null,
league: event?.league?.name || "LCK",
leagueSlug: event?.league?.slug || "lck",
tournamentId: event?.tournament?.id || null,
tournamentName: event?.tournament?.name || null,
blockName: event?.blockName || null,
date: koreaTime.date,
kickOff: koreaTime.kickOff,
startTime: event?.startTime || null,
status,
strategy: event?.match?.strategy || null,
team1: team1 ? stripAliasTokens(team1) : null,
team2: team2 ? stripAliasTokens(team2) : null,
score,
winner: determineWinner(team1, team2, score, status),
flags: Array.isArray(event?.match?.flags) ? event.match.flags : [],
};
}
function normalizeStandingsResponse(payload, options = {}) {
const standings = Array.isArray(payload?.data?.standings) ? payload.data.standings : [];
const requestedTeam = options.team ? resolveTeamQuery(options.team) : null;
const tournament = options.tournament || null;
const stage = standings.flatMap((entry) => entry?.stages || []).find((candidate) => Array.isArray(candidate?.sections));
const section = stage?.sections?.find((candidate) => Array.isArray(candidate?.rankings)) || null;
const rows = [];
for (const ranking of section?.rankings || []) {
for (const team of ranking.teams || []) {
const resolved = resolveTeamPayload(team);
const row = {
rank: normalizeNumber(ranking.ordinal),
team: stripAliasTokens(resolved),
wins: normalizeNumber(team?.record?.wins) ?? 0,
losses: normalizeNumber(team?.record?.losses) ?? 0,
};
if (!requestedTeam || teamMatchesRequestedTeam(resolved, requestedTeam)) {
rows.push(row);
}
}
}
rows.sort((left, right) => {
if (left.rank !== right.rank) {
return left.rank - right.rank;
}
return left.team.name.localeCompare(right.team.name, "en");
});
return {
tournamentId: tournament?.id || null,
tournamentName: tournament?.slug || tournament?.name || null,
stage: stage
? {
id: stage.id,
name: stage.name,
slug: stage.slug,
}
: null,
sectionName: section?.name || null,
filteredTeam: requestedTeam
? {
input: requestedTeam.input,
canonicalId: requestedTeam.canonicalId,
normalized: requestedTeam.currentName,
}
: null,
rows,
};
}
function normalizeTournamentList(payload) {
const tournaments = payload?.data?.leagues?.flatMap((league) => league?.tournaments || []) || [];
return tournaments
.map((tournament) => ({
id: tournament.id,
slug: tournament.slug,
startDate: tournament.startDate,
endDate: tournament.endDate,
}))
.sort((left, right) => left.startDate.localeCompare(right.startDate));
}
function normalizeEventDetailsResponse(payload) {
const event = payload?.data?.event || payload?.event || {};
const matchTeams = Array.isArray(event?.match?.teams) ? event.match.teams.map(resolveTeamPayload) : [];
const byId = new Map(matchTeams.map((team) => [String(team.id), team]));
return {
eventId: event?.id || null,
streams: Array.isArray(event?.streams) ? event.streams : [],
games: (event?.match?.games || []).map((game) => ({
id: game?.id || null,
number: normalizeNumber(game?.number),
state: game?.state || null,
teams: (game?.teams || []).map((team) => {
const resolved = byId.get(String(team?.id)) || resolveTeamPayload(team);
return {
side: team?.side || null,
team: stripAliasTokens(resolved),
};
}),
})),
};
}
function normalizeLiveGameResponse(windowPayload, detailsPayload, options = {}) {
const gameMetadata = windowPayload?.gameMetadata || {};
const windowFrames = Array.isArray(windowPayload?.frames) ? windowPayload.frames : [];
const detailsFrames = Array.isArray(detailsPayload?.frames) ? detailsPayload.frames : [];
const latestWindow = windowFrames.at(-1) || null;
const latestDetails = detailsFrames.at(-1) || null;
if (!latestWindow && !latestDetails) {
return null;
}
const participantDirectory = buildParticipantDirectory(gameMetadata, latestWindow, latestDetails);
const blue = normalizeLiveSide("blue", latestWindow?.blueTeam, participantDirectory);
const red = normalizeLiveSide("red", latestWindow?.redTeam, participantDirectory);
const firstTimestamp = windowFrames[0]?.rfc460Timestamp || detailsFrames[0]?.rfc460Timestamp || null;
const lastTimestamp = latestWindow?.rfc460Timestamp || latestDetails?.rfc460Timestamp || null;
const normalized = {
gameId: options.gameId || windowPayload?.esportsGameId || null,
matchId: windowPayload?.esportsMatchId || options.matchId || null,
patchVersion: gameMetadata.patchVersion || null,
gameState: latestWindow?.gameState || null,
updatedAt: lastTimestamp,
durationSeconds: firstTimestamp && lastTimestamp
? Math.max(0, Math.round((new Date(lastTimestamp) - new Date(firstTimestamp)) / 1000))
: null,
blueTeam: blue,
redTeam: red,
goldDiff: blue.totalGold !== null && red.totalGold !== null ? blue.totalGold - red.totalGold : null,
killDiff: blue.totalKills !== null && red.totalKills !== null ? blue.totalKills - red.totalKills : null,
};
return hasMeaningfulLiveStats(normalized) ? normalized : null;
}
function hasMeaningfulLiveStats(live) {
if (!live) {
return false;
}
if (live.gameState && live.gameState !== "in_game") {
return true;
}
const teamSignals = [live.blueTeam, live.redTeam].some((team) => {
if (!team) {
return false;
}
return (team.totalGold ?? 0) > 0
|| (team.totalKills ?? 0) > 0
|| (team.towers ?? 0) > 0
|| (team.barons ?? 0) > 0
|| (team.dragonCount ?? 0) > 0
|| team.participants.some((participant) =>
(participant.level ?? 0) > 1
|| (participant.creepScore ?? 0) > 0
|| (participant.totalGold ?? 0) > 0
|| (participant.kills ?? 0) > 0
|| (participant.deaths ?? 0) > 0
|| (participant.assists ?? 0) > 0
|| participant.items.length > 0,
);
});
return teamSignals;
}
function buildParticipantDirectory(gameMetadata, latestWindow, latestDetails) {
const directory = new Map();
const sideMaps = [
["blue", gameMetadata?.blueTeamMetadata?.participantMetadata || [], latestWindow?.blueTeam?.participants || []],
["red", gameMetadata?.redTeamMetadata?.participantMetadata || [], latestWindow?.redTeam?.participants || []],
];
const detailParticipants = new Map((latestDetails?.participants || []).map((participant) => [participant.participantId, participant]));
for (const [side, metadataRows, windowRows] of sideMaps) {
const windowParticipants = new Map(windowRows.map((participant) => [participant.participantId, participant]));
for (const metadata of metadataRows) {
directory.set(metadata.participantId, {
side,
participantId: metadata.participantId,
esportsPlayerId: metadata.esportsPlayerId || null,
summonerName: metadata.summonerName || null,
championId: metadata.championId || null,
role: metadata.role || null,
window: windowParticipants.get(metadata.participantId) || null,
details: detailParticipants.get(metadata.participantId) || null,
});
}
}
return directory;
}
function normalizeLiveSide(side, teamSnapshot, participantDirectory) {
const participants = [...participantDirectory.values()]
.filter((participant) => participant.side === side)
.sort((left, right) => left.participantId - right.participantId)
.map((participant) => ({
participantId: participant.participantId,
esportsPlayerId: participant.esportsPlayerId,
summonerName: participant.summonerName,
championId: participant.championId,
role: participant.role,
level: normalizeNumber(participant.window?.level ?? participant.details?.level),
kills: normalizeNumber(participant.window?.kills ?? participant.details?.kills),
deaths: normalizeNumber(participant.window?.deaths ?? participant.details?.deaths),
assists: normalizeNumber(participant.window?.assists ?? participant.details?.assists),
creepScore: normalizeNumber(participant.window?.creepScore ?? participant.details?.creepScore),
totalGold: normalizeNumber(participant.window?.totalGold ?? participant.details?.totalGoldEarned),
items: Array.isArray(participant.details?.items) ? participant.details.items : [],
}));
return {
side,
totalGold: normalizeNumber(teamSnapshot?.totalGold),
totalKills: normalizeNumber(teamSnapshot?.totalKills),
towers: normalizeNumber(teamSnapshot?.towers),
inhibitors: normalizeNumber(teamSnapshot?.inhibitors),
barons: normalizeNumber(teamSnapshot?.barons),
dragons: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons : [],
dragonCount: Array.isArray(teamSnapshot?.dragons) ? teamSnapshot.dragons.length : 0,
participants,
};
}
function resolveTournamentForDate(tournaments, date) {
const requestedDate = normalizeDateInput(date).isoDate;
const direct = tournaments.find((tournament) => tournament.startDate <= requestedDate && requestedDate <= tournament.endDate);
if (direct) {
return direct;
}
const older = tournaments.filter((tournament) => tournament.startDate <= requestedDate);
return older.at(-1) || tournaments[0] || null;
}
function matchIncludesRequestedTeam(match, requestedTeam) {
return teamMatchesRequestedTeam(match.team1, requestedTeam) || teamMatchesRequestedTeam(match.team2, requestedTeam);
}
function teamMatchesRequestedTeam(team, requestedTeam) {
if (!team) {
return false;
}
const resolved = resolveTeamPayload(team);
if (requestedTeam.canonicalId && resolved.canonicalId) {
return requestedTeam.canonicalId === resolved.canonicalId;
}
return resolved.aliasTokens.has(requestedTeam.token);
}
function determineWinner(team1, team2, score, status) {
if (!status.finished || score.team1 === null || score.team2 === null) {
return null;
}
if (score.team1 === score.team2) {
return "draw";
}
return score.team1 > score.team2 ? team1?.canonicalId || team1?.code || "team1" : team2?.canonicalId || team2?.code || "team2";
}
function compareMatches(left, right) {
const leftKey = `${left.date}T${left.kickOff}`;
const rightKey = `${right.date}T${right.kickOff}`;
if (leftKey !== rightKey) {
return leftKey.localeCompare(rightKey);
}
return String(left.matchId || "").localeCompare(String(right.matchId || ""));
}
function normalizeNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
module.exports = {
buildParticipantDirectory,
compareMatches,
eventToKoreaDateTime,
normalizeDateInput,
hasMeaningfulLiveStats,
normalizeEventDetailsResponse,
normalizeEventMatch,
normalizeLiveGameResponse,
normalizeLiveSide,
normalizeMatchStatus,
normalizeScheduleResponse,
normalizeStandingsResponse,
normalizeTournamentList,
resolveTournamentForDate,
};

View file

@ -0,0 +1,189 @@
function normalizeToken(value) {
return String(value || "")
.normalize("NFKC")
.toUpperCase()
.replace(/[^0-9A-Z가-힣]+/g, "");
}
const TEAM_REGISTRY = [
{
canonicalId: "hle",
teamIds: ["100205573496804586"],
currentName: "Hanwha Life Esports",
aliases: ["Hanwha Life Esports", "Hanwha", "HLE", "한화", "한화생명", "한화생명e스포츠"],
},
{
canonicalId: "gen",
teamIds: ["100205573495116443"],
currentName: "Gen.G Esports",
aliases: ["Gen.G", "Gen.G Esports", "GEN", "젠지", "젠지 e스포츠"],
},
{
canonicalId: "t1",
teamIds: ["98767991853197861"],
currentName: "T1",
aliases: ["T1", "SKT", "SKT T1", "SK Telecom T1", "SK텔레콤 T1"],
},
{
canonicalId: "dk",
teamIds: ["100725845018863243"],
currentName: "Dplus KIA",
aliases: ["Dplus KIA", "DK", "Damwon KIA", "DWG KIA", "DAMWON Gaming", "담원", "담원 기아", "디플러스 기아"],
},
{
canonicalId: "kt",
teamIds: ["99566404579461230"],
currentName: "kt Rolster",
aliases: ["kt Rolster", "KT", "KT Rolster", "케이티", "케이티 롤스터"],
},
{
canonicalId: "ns",
teamIds: ["102747101565183056"],
currentName: "NONGSHIM RED FORCE",
aliases: ["NONGSHIM RED FORCE", "Nongshim", "NS", "농심", "농심 레드포스", "Team Dynamics"],
},
{
canonicalId: "bro",
teamIds: ["105505619546859895"],
currentName: "HANJIN BRION",
aliases: ["HANJIN BRION", "BRION", "BRO", "OKSavingsBank BRION", "Fredit BRION", "브리온", "한진 브리온"],
},
{
canonicalId: "bfx",
teamIds: ["100725845022060229"],
currentName: "BNK FEARX",
aliases: ["BNK FEARX", "FEARX", "BFX", "Liiv SANDBOX", "SANDBOX Gaming", "리브 샌드박스", "샌드박스", "피어엑스"],
},
{
canonicalId: "drx",
teamIds: ["99566404585387054"],
currentName: "KIWOOM DRX",
aliases: ["KIWOOM DRX", "DRX", "DragonX", "Kingzone DragonX", "킹존 드래곤X", "드래곤X", "키움 DRX"],
},
{
canonicalId: "dnf",
teamIds: ["99566404581868574"],
currentName: "DN SOOPers",
aliases: [
"DN SOOPers",
"DNS",
"DN FREECS",
"DNF",
"Kwangdong Freecs",
"KDF",
"광동 프릭스",
"광동",
"Afreeca Freecs",
"아프리카 프릭스",
"Freecs"
],
},
];
const REGISTRY_BY_ID = new Map();
const REGISTRY_BY_TOKEN = new Map();
for (const entry of TEAM_REGISTRY) {
entry.aliasTokens = new Set();
for (const tokenSource of [entry.canonicalId, entry.currentName, ...(entry.aliases || [])]) {
const token = normalizeToken(tokenSource);
if (!token) {
continue;
}
entry.aliasTokens.add(token);
REGISTRY_BY_TOKEN.set(token, entry);
}
for (const teamId of entry.teamIds || []) {
REGISTRY_BY_ID.set(String(teamId), entry);
}
}
function resolveTeamQuery(query) {
const input = String(query || "").trim();
const token = normalizeToken(input);
const entry = REGISTRY_BY_TOKEN.get(token);
if (!entry) {
return {
input,
token,
canonicalId: null,
currentName: input,
aliasTokens: new Set(token ? [token] : []),
};
}
return {
input,
token,
canonicalId: entry.canonicalId,
currentName: entry.currentName,
aliasTokens: new Set(entry.aliasTokens),
};
}
function resolveTeamPayload(team) {
const teamId = String(team?.id || "");
const entry = REGISTRY_BY_ID.get(teamId)
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.name))
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.code))
|| REGISTRY_BY_TOKEN.get(normalizeToken(team?.slug));
if (!entry) {
return {
id: team?.id || null,
slug: team?.slug || null,
code: team?.code || null,
name: team?.name || null,
image: team?.image || null,
canonicalId: null,
currentName: team?.name || null,
aliasTokens: new Set([
normalizeToken(team?.id),
normalizeToken(team?.slug),
normalizeToken(team?.code),
normalizeToken(team?.name),
].filter(Boolean)),
};
}
return {
id: team?.id || null,
slug: team?.slug || null,
code: team?.code || null,
name: team?.name || entry.currentName,
image: team?.image || null,
canonicalId: entry.canonicalId,
currentName: entry.currentName,
aliasTokens: new Set([
normalizeToken(team?.id),
normalizeToken(team?.slug),
normalizeToken(team?.code),
normalizeToken(team?.name),
...entry.aliasTokens,
].filter(Boolean)),
};
}
function stripAliasTokens(team) {
return {
id: team.id,
slug: team.slug,
code: team.code,
name: team.name,
image: team.image,
canonicalId: team.canonicalId,
currentName: team.currentName,
};
}
module.exports = {
TEAM_REGISTRY,
normalizeToken,
resolveTeamPayload,
resolveTeamQuery,
stripAliasTokens,
};

View file

@ -0,0 +1,47 @@
{
"data": {
"event": {
"id": "match-1",
"streams": [
{
"parameter": "lck",
"locale": "ko-KR",
"provider": "afreeca"
}
],
"match": {
"games": [
{
"id": "game-1",
"number": 1,
"state": "inProgress",
"teams": [
{
"id": "100205573496804586",
"side": "blue"
},
{
"id": "98767991853197861",
"side": "red"
}
]
}
],
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports"
},
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1"
}
]
}
}
}
}

View file

@ -0,0 +1,88 @@
{
"esportsGameId": "game-1",
"esportsMatchId": "match-1",
"frames": [
{
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
"participants": [
{
"participantId": 1,
"level": 12,
"kills": 2,
"deaths": 0,
"assists": 4,
"creepScore": 138,
"currentGold": 1200
},
{
"participantId": 2,
"level": 10,
"kills": 0,
"deaths": 2,
"assists": 1,
"creepScore": 112,
"currentGold": 800
},
{
"participantId": 6,
"level": 11,
"kills": 1,
"deaths": 1,
"assists": 0,
"creepScore": 126,
"currentGold": 950
},
{
"participantId": 7,
"level": 9,
"kills": 0,
"deaths": 2,
"assists": 1,
"creepScore": 101,
"currentGold": 700
}
]
},
{
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
"participants": [
{
"participantId": 1,
"level": 14,
"kills": 5,
"deaths": 0,
"assists": 6,
"creepScore": 165,
"currentGold": 2100
},
{
"participantId": 2,
"level": 12,
"kills": 0,
"deaths": 4,
"assists": 2,
"creepScore": 130,
"currentGold": 900
},
{
"participantId": 6,
"level": 12,
"kills": 1,
"deaths": 3,
"assists": 0,
"creepScore": 140,
"currentGold": 1100
},
{
"participantId": 7,
"level": 11,
"kills": 0,
"deaths": 3,
"assists": 1,
"creepScore": 119,
"currentGold": 750
}
]
}
]
}

View file

@ -0,0 +1,118 @@
{
"esportsGameId": "game-1",
"esportsMatchId": "match-1",
"gameMetadata": {
"patchVersion": "16.6.753.8272",
"blueTeamMetadata": {
"participantMetadata": [
{
"participantId": 1,
"summonerName": "HLE Zeus",
"championId": "Aatrox",
"role": "top"
},
{
"participantId": 2,
"summonerName": "HLE Peanut",
"championId": "Vi",
"role": "jungle"
}
]
},
"redTeamMetadata": {
"participantMetadata": [
{
"participantId": 6,
"summonerName": "T1 Doran",
"championId": "Gnar",
"role": "top"
},
{
"participantId": 7,
"summonerName": "T1 Oner",
"championId": "Sejuani",
"role": "jungle"
}
]
}
},
"frames": [
{
"rfc460Timestamp": "2026-04-01T09:00:00.000Z",
"gameState": "in_game",
"blueTeam": {
"totalGold": 24000,
"inhibitors": 0,
"towers": 3,
"barons": 0,
"totalKills": 2,
"dragons": [
"infernal"
],
"participants": [
{
"participantId": 1
},
{
"participantId": 2
}
]
},
"redTeam": {
"totalGold": 23200,
"inhibitors": 0,
"towers": 2,
"barons": 0,
"totalKills": 1,
"dragons": [],
"participants": [
{
"participantId": 6
},
{
"participantId": 7
}
]
}
},
{
"rfc460Timestamp": "2026-04-01T09:05:00.000Z",
"gameState": "in_game",
"blueTeam": {
"totalGold": 29800,
"inhibitors": 0,
"towers": 5,
"barons": 1,
"totalKills": 6,
"dragons": [
"infernal",
"hextech"
],
"participants": [
{
"participantId": 1
},
{
"participantId": 2
}
]
},
"redTeam": {
"totalGold": 26200,
"inhibitors": 0,
"towers": 2,
"barons": 0,
"totalKills": 1,
"dragons": [],
"participants": [
{
"participantId": 6
},
{
"participantId": 7
}
]
}
}
]
}

View file

@ -0,0 +1,5 @@
league,matchid,date,patch,side,teamname,opponentteam,playername,position,champion,opponentchampion,result,gd15,csd15,xpd15,drg,bn,blindpick,counterpick
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Zeus,top,Aatrox,Gnar,win,1200,18,340,100,100,0,1
LCK,match-1,2026-04-01,16.6.753.8272,blue,Hanwha Life Esports,T1,HLE Peanut,jungle,Vi,Sejuani,win,800,5,280,100,100,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Doran,top,Gnar,Aatrox,loss,-1200,-18,-340,0,0,1,0
LCK,match-1,2026-04-01,16.6.753.8272,red,T1,Hanwha Life Esports,T1 Oner,jungle,Sejuani,Vi,loss,-800,-5,-280,0,0,0,1
1 league matchid date patch side teamname opponentteam playername position champion opponentchampion result gd15 csd15 xpd15 drg bn blindpick counterpick
2 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Zeus top Aatrox Gnar win 1200 18 340 100 100 0 1
3 LCK match-1 2026-04-01 16.6.753.8272 blue Hanwha Life Esports T1 HLE Peanut jungle Vi Sejuani win 800 5 280 100 100 1 0
4 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Doran top Gnar Aatrox loss -1200 -18 -340 0 0 1 0
5 LCK match-1 2026-04-01 16.6.753.8272 red T1 Hanwha Life Esports T1 Oner jungle Sejuani Vi loss -800 -5 -280 0 0 0 1

View file

@ -0,0 +1,57 @@
{
"data": {
"schedule": {
"pages": {
"older": null,
"newer": null
},
"events": [
{
"id": "event-1",
"startTime": "2026-04-01T09:00:00.000Z",
"state": "inProgress",
"blockName": "Regular Season",
"league": {
"slug": "lck",
"name": "LCK"
},
"tournament": {
"id": "tournament-2026",
"name": "LCK 2026 Spring",
"slug": "lck-2026-spring"
},
"match": {
"id": "match-1",
"strategy": {
"type": "bestOf",
"count": 3
},
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports",
"result": {
"gameWins": 1
}
},
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1",
"result": {
"gameWins": 0
}
}
],
"flags": [
"featured"
]
}
}
]
}
}
}

View file

@ -0,0 +1,52 @@
{
"data": {
"standings": [
{
"stages": [
{
"id": "stage-1",
"name": "Regular Season",
"slug": "regular-season",
"sections": [
{
"name": "Rankings",
"rankings": [
{
"ordinal": 1,
"teams": [
{
"id": "100205573496804586",
"name": "Hanwha Life Esports",
"code": "HLE",
"slug": "hanwha-life-esports",
"record": {
"wins": 5,
"losses": 1
}
}
]
},
{
"ordinal": 2,
"teams": [
{
"id": "98767991853197861",
"name": "T1",
"code": "T1",
"slug": "t1",
"record": {
"wins": 4,
"losses": 2
}
}
]
}
]
}
]
}
]
}
]
}
}

View file

@ -0,0 +1,18 @@
{
"data": {
"leagues": [
{
"id": "98767991310872058",
"slug": "lck",
"tournaments": [
{
"id": "tournament-2026",
"slug": "lck-2026-spring",
"startDate": "2026-03-01",
"endDate": "2026-05-01"
}
]
}
]
}
}

View file

@ -0,0 +1,170 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildHistoricalAnalytics,
getGameAnalysis,
getLckSummary,
getMatchAnalysis,
getMatchResults,
getStandings,
parseOracleCsv,
} = require("../src/index");
const {
normalizeScheduleResponse,
normalizeStandingsResponse,
} = require("../src/parse");
const fixturesDir = path.join(__dirname, "fixtures");
function readFixture(name) {
return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8"));
}
const schedulePayload = readFixture("schedule-2026-04-01.json");
const tournamentsPayload = readFixture("tournaments-2026.json");
const standingsPayload = readFixture("standings-2026.json");
const eventDetailsPayload = readFixture("event-details-2026-04-01.json");
const liveWindowPayload = readFixture("live-window-game-1.json");
const liveDetailsPayload = readFixture("live-details-game-1.json");
const oracleCsv = fs.readFileSync(path.join(fixturesDir, "oracle-sample.csv"), "utf8");
test("normalizeScheduleResponse filters requested LCK date and Korean team aliases", () => {
const result = normalizeScheduleResponse(schedulePayload, {
date: "2026-04-01",
team: "한화",
});
assert.equal(result.queryDate, "2026-04-01");
assert.equal(result.matches.length, 1);
assert.equal(result.filteredTeam.canonicalId, "hle");
assert.equal(result.matches[0].team1.currentName, "Hanwha Life Esports");
assert.equal(result.matches[0].team2.currentName, "T1");
assert.deepEqual(result.matches[0].score, { team1: 1, team2: 0 });
});
test("normalizeStandingsResponse keeps the LCK standings shape and alias resolution", () => {
const table = normalizeStandingsResponse(standingsPayload, {
tournament: {
id: "tournament-2026",
slug: "lck-2026-spring",
name: "LCK 2026 Spring",
},
team: "T1",
});
assert.equal(table.tournamentId, "tournament-2026");
assert.equal(table.rows.length, 1);
assert.equal(table.rows[0].team.canonicalId, "t1");
assert.equal(table.rows[0].wins, 4);
assert.equal(table.rows[0].losses, 2);
});
test("public fetchers compose summary, standings, live details, and match analysis", async () => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
const target = String(url);
calls.push({
target,
headers: options.headers || {},
});
if (target.includes("getSchedule")) {
return makeResponse(schedulePayload);
}
if (target.includes("getTournamentsForLeague")) {
return makeResponse(tournamentsPayload);
}
if (target.includes("getStandings")) {
return makeResponse(standingsPayload);
}
if (target.includes("getEventDetails")) {
return makeResponse(eventDetailsPayload);
}
if (target.includes("/window/game-1")) {
return makeResponse(liveWindowPayload);
}
if (target.includes("/details/game-1")) {
return makeResponse(liveDetailsPayload);
}
throw new Error(`unexpected url: ${target}`);
};
try {
const results = await getMatchResults("2026-04-01", { team: "한화" });
const standings = await getStandings({ date: "2026-04-01", team: "T1" });
const summary = await getLckSummary("2026-04-01", {
team: "한화",
includeStandings: true,
});
const analysis = await getMatchAnalysis("2026-04-01", {
team: "한화",
historicalDataset: buildHistoricalAnalytics(oracleCsv),
});
assert.equal(results.matches.length, 1);
assert.equal(results.matches[0].games[0].teams[0].team.currentName, "Hanwha Life Esports");
assert.equal(results.matches[0].live.killDiff, 5);
assert.equal(standings.rows[0].team.currentName, "T1");
assert.equal(summary.standings.rows[0].team.currentName, "Hanwha Life Esports");
assert.equal(analysis.matches[0].analyses[0].draft.overallEdge, "blue");
assert.equal(analysis.matches[0].powerPreview.teamA.teamId, "hle");
assert.ok(
calls.some((call) => call.headers["x-api-key"]),
"expected Riot API requests to include an x-api-key header",
);
} finally {
global.fetch = originalFetch;
}
});
test("historical analytics parse Oracle-style CSV and power rankings", () => {
const parsedRows = parseOracleCsv(oracleCsv);
const historical = buildHistoricalAnalytics(parsedRows);
assert.equal(parsedRows.length, 4);
assert.equal(historical.rows.length, 4);
assert.equal(historical.teamPowerRatings[0].teamId, "hle");
assert.equal(historical.teamPowerRatings[0].wins, 2);
assert.equal(historical.patchMeta[0].patch, "16.6.753.8272");
assert.equal(historical.matchupStats[0].champion, "Aatrox");
});
test("getGameAnalysis computes turning points and draft context from injected live payloads", async () => {
const historical = buildHistoricalAnalytics(oracleCsv);
const analysis = await getGameAnalysis("game-1", {
matchId: "match-1",
number: 1,
state: "inProgress",
historicalDataset: historical,
liveWindowPayload,
liveDetailsPayload,
});
assert.equal(analysis.gameId, "game-1");
assert.equal(analysis.patch, "16.6.753.8272");
assert.equal(analysis.current.goldDiff, 3600);
assert.ok(analysis.turningPoints.length >= 1);
assert.equal(analysis.turningPoints[0].favoredSide, "blue");
assert.equal(analysis.draft.roleMatchups[0].role, "top");
assert.equal(analysis.meta.topPicks[0].champion, "Aatrox");
});
function makeResponse(body) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {
"content-type": "application/json",
},
});
}

182
real-estate-search/SKILL.md Normal file
View file

@ -0,0 +1,182 @@
---
name: real-estate-search
description: Korean apartment/officetel/villa/single-house real transaction price and rent lookups via k-skill-proxy. Based on tae0y's real-estate-mcp and MOLIT public data APIs.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# Korean Real Estate Search
## What this skill does
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/real-estate/...` 로 요청해서 한국 부동산 실거래가/전월세 데이터를 조회한다. 국토교통부(MOLIT) 실거래가 신고 데이터를 기반으로 한다.
## When to use
- "잠실 리센츠 2024년 매매 실거래가 찾아줘"
- "마포구 아파트 전세 실거래가 보여줘"
- "성수동 오피스텔 월세 실거래 데이터 볼래"
- "강남구 연립다세대 매매 실거래가"
- "용산구 상업업무용 건물 거래 내역"
## When not to use
- 해외 부동산 시세/거래 조회
- 실거래가가 아닌 민간 호가/매물 비교만 필요한 경우
- 세금/등기/중개 법률자문처럼 판단이 필요한 경우
- 청약홈 분양/당첨 조회 (아직 미지원)
## Inputs
- `q`: 지역명 (region-code endpoint, 예: `"서울 강남구"`, `"마포구"`)
- `lawd_cd`: 5자리 법정동 코드 (transaction endpoint, 예: `"11680"`)
- `deal_ymd`: 6자리 거래년월 YYYYMM (예: `"202403"`)
- `num_of_rows`: 조회 건수 (기본 100, 최대 1000)
## Prerequisites
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Supported endpoints
### 지역코드 조회
```
GET /v1/real-estate/region-code?q={지역명}
```
### 실거래가/전월세 조회
```
GET /v1/real-estate/:assetType/:dealType?lawd_cd={코드}&deal_ymd={년월}
```
| assetType | dealType | 설명 |
|---|---|---|
| `apartment` | `trade` | 아파트 매매 |
| `apartment` | `rent` | 아파트 전월세 |
| `officetel` | `trade` | 오피스텔 매매 |
| `officetel` | `rent` | 오피스텔 전월세 |
| `villa` | `trade` | 연립다세대 매매 |
| `villa` | `rent` | 연립다세대 전월세 |
| `single-house` | `trade` | 단독/다가구 매매 |
| `single-house` | `rent` | 단독/다가구 전월세 |
| `commercial` | `trade` | 상업업무용 매매 |
`commercial/rent`는 지원하지 않는다.
## Example requests
지역코드 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/region-code' \
--data-urlencode 'q=강남구'
```
아파트 매매 실거래가 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/apartment/trade' \
--data-urlencode 'lawd_cd=11680' \
--data-urlencode 'deal_ymd=202403'
```
오피스텔 전월세 조회:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/officetel/rent' \
--data-urlencode 'lawd_cd=11680' \
--data-urlencode 'deal_ymd=202403'
```
## Response shape
### 지역코드 응답
```json
{
"results": [
{ "lawd_cd": "11680", "name": "서울특별시 강남구" }
],
"query": "강남구",
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 매매 실거래가 응답
```json
{
"items": [
{
"name": "래미안 퍼스티지",
"district": "반포동",
"area_m2": 84.99,
"floor": 12,
"price_10k": 245000,
"deal_date": "2024-03-15",
"build_year": 2009,
"deal_type": "중개거래"
}
],
"summary": {
"median_price_10k": 230000,
"min_price_10k": 180000,
"max_price_10k": 310000,
"sample_count": 42
},
"query": { "asset_type": "apartment", "deal_type": "trade", "lawd_cd": "11680", "deal_ymd": "202403" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 전월세 응답
매매와 동일 구조이나 아이템에 `deposit_10k`, `monthly_rent_10k`, `contract_type` 이 포함되고, summary에 `median_deposit_10k`, `monthly_rent_avg_10k` 등이 들어간다.
## Response policy
- 실거래가/전월세 요청이면 `region-code` endpoint로 행정구역 코드를 먼저 확인한 뒤 자산 타입별 endpoint로 조회한다.
- 아파트 매매는 `apartment/trade`, 아파트 전월세는 `apartment/rent` 를 우선 사용한다.
- 오피스텔/빌라/단독주택/상업업무용은 자산 타입에 맞는 endpoint로 라우팅한다.
- 사용자가 동/건물명/연월을 덜 줬으면 지역, 단지명, 기준 월을 먼저 보강한다.
- 실거래가와 호가를 섞어 말하지 않는다. 이 스킬은 국토교통부 기반 실거래/전월세 신고 데이터를 다룬다.
## Keep the answer compact
- 지역명 + 자산 타입 + 거래년월
- 거래 건수 (summary.sample_count)
- 가격 요약: 중위값, 최소, 최대
- 상위 3-5건 대표 거래 (이름, 면적, 층, 가격, 날짜)
- 전월세면 보증금 + 월세 요약도 포함
## Failure modes
- `lawd_cd` 또는 `deal_ymd` 형식이 잘못되면 400 응답
- 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 503 응답
- upstream MOLIT API 오류면 502 + `molit_api_XXX` 에러 코드
- 해당 지역/기간에 데이터가 없으면 빈 `items` 배열 반환
## Done when
- 요청 자산 타입에 맞는 endpoint를 선택했다.
- 필요한 경우 `region-code` 로 지역코드를 먼저 확인했다.
- 실거래가/전월세 결과를 조회하고 요약했다.
- 원본 데이터 출처(국토교통부 실거래가 신고)를 함께 남겼다.
## Notes
- 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`
- 공식 데이터 출처: 공공데이터포털 (`https://www.data.go.kr`)
- 가격 단위: `price_10k`, `deposit_10k` = 만원 단위 (예: 245000 = 24억 5천만원)
- 취소된 거래는 서버에서 자동 필터링된다.

View file

@ -0,0 +1,43 @@
#!/usr/bin/env node
// One-time script: convert upstream region_codes.txt to a deduplicated
// 5-digit LAWD_CD JSON lookup used by k-skill-proxy.
//
// Usage:
// node scripts/build-region-codes.js <path-to-region_codes.txt>
//
// Output: packages/k-skill-proxy/src/region-codes.json
const { readFileSync, writeFileSync } = require("node:fs");
const { resolve } = require("node:path");
const inputPath = process.argv[2];
if (!inputPath) {
console.error("Usage: node scripts/build-region-codes.js <region_codes.txt>");
process.exit(1);
}
const raw = readFileSync(resolve(inputPath), "utf-8");
const lines = raw.split("\n").slice(1); // skip header
const codes = new Map();
for (const line of lines) {
const parts = line.split("\t");
if (parts.length < 3) continue;
const [fullCode, name, status] = parts;
if (status.trim() !== "존재") continue;
const lawdCd = fullCode.slice(0, 5);
if (codes.has(lawdCd)) continue; // keep first (gu/gun-level) occurrence
codes.set(lawdCd, name.trim());
}
const sorted = Object.fromEntries(
[...codes.entries()].sort(([a], [b]) => a.localeCompare(b))
);
const outPath = resolve(__dirname, "../packages/k-skill-proxy/src/region-codes.json");
writeFileSync(outPath, JSON.stringify(sorted, null, 2) + "\n", "utf-8");
console.log(`Wrote ${Object.keys(sorted).length} entries to ${outPath}`);

View file

@ -0,0 +1,13 @@
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = (
Path(__file__).resolve().parent.parent / "korean-spell-check" / "scripts" / "korean_spell_check.py"
)
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled spell-check helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

13
scripts/sillok_search.py Normal file
View file

@ -0,0 +1,13 @@
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = (
Path(__file__).resolve().parent.parent / "joseon-sillok-search" / "scripts" / "sillok_search.py"
)
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled sillok helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const childProcess = require("node:child_process");
@ -18,6 +19,47 @@ function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findSection(doc, heading) {
const escaped = escapeRegex(heading);
const match = doc.match(new RegExp(`${escaped}[\\s\\S]*?(?=\\n## |\\n### |$)`));
assert.ok(match, `expected section headed by "${heading}"`);
return match[0];
}
function assertOliveYoungCloneFallbackCommands(doc, label) {
assert.match(doc, /node dist\/bin\.js health/, `${label} should document the runnable local health command`);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/stores --keyword 명동 --limit 5 --json/,
`${label} should document the runnable local store lookup command`,
);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/products --keyword 선크림 --size 5 --json/,
`${label} should document the runnable local product lookup command`,
);
assert.match(
doc,
/node dist\/bin\.js get \/api\/oliveyoung\/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json/,
`${label} should document the runnable local inventory lookup command`,
);
assert.doesNotMatch(doc, /^\s*npx daiso\b/m, `${label} should not publish broken clone-local npx commands`);
}
function assertOliveYoungCloneFallbackShorthand(doc, label) {
assert.match(
doc,
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && cd daiso-mcp && npm install && npm run build/,
`${label} should include a runnable shorthand that changes into the clone before install/build`,
);
assert.doesNotMatch(
doc,
/git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git && npm install && npm run build/,
`${label} should not publish the broken shorthand that skips cd daiso-mcp`,
);
}
function extractQuotedEntries(block, indent) {
return block
.split("\n")
@ -179,10 +221,68 @@ test("repository docs advertise the used-car-price-search skill", () => {
assert.match(install, /--skill used-car-price-search/);
assert.match(
install,
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
);
});
test("repository docs advertise the lck-analytics skill and package", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "lck-analytics.md");
const skillPath = path.join(repoRoot, "lck-analytics", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/lck-analytics.md to exist");
assert.ok(fs.existsSync(skillPath), "expected lck-analytics/SKILL.md to exist");
assert.match(readme, /\| LCK 경기 분석 \|/);
assert.match(readme, /\[LCK 경기 분석 가이드\]\(docs\/features\/lck-analytics\.md\)/);
assert.match(install, /--skill lck-analytics/);
assert.match(install, /npm install -g .*lck-analytics/);
});
test("lck-analytics docs and skill credit the original author and reference repo", () => {
const skill = read(path.join("lck-analytics", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "lck-analytics.md"));
const packageReadme = read(path.join("packages", "lck-analytics", "README.md"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [skill, featureDoc, packageReadme]) {
assert.match(doc, /jerjangmin/);
assert.match(doc, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
assert.match(doc, /Riot|LoL Esports|Oracle(?:'s)? Elixir/i);
}
assert.match(sources, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
});
test("repository docs advertise the korean-spell-check skill and usage constraints", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-spell-check.md");
const skillPath = path.join(repoRoot, "korean-spell-check", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "korean-spell-check.md"));
const skill = read(path.join("korean-spell-check", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-spell-check.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-spell-check/SKILL.md to exist");
assert.match(readme, /\| 한국어 맞춤법 검사 \|/);
assert.match(readme, /\[한국어 맞춤법 검사 가이드\]\(docs\/features\/korean-spell-check\.md\)/);
assert.match(install, /--skill korean-spell-check/);
assert.match(skill, /비상업적 용도|개인이나 학생만 무료/);
assert.match(skill, /robots\.txt/i);
assert.match(skill, /청크|chunk/i);
assert.match(skill, /.*.*/s);
assert.match(featureDoc, /old_speller\/results/);
assert.match(featureDoc, /Cloudflare|403/);
assert.match(featureDoc, /python3 scripts\/korean_spell_check\.py/);
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/speller\//);
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/old_speller\//);
assert.match(sources, /https:\/\/nara-speller\.co\.kr\/robots\.txt/);
assert.match(roadmap, /한국어 맞춤법 검사 스킬 출시/);
});
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
const skill = read(path.join("used-car-price-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
@ -649,6 +749,77 @@ test("daiso-product-search docs record the shipped feature and official sources"
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("repository docs advertise the olive-young-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "olive-young-search.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/olive-young-search.md to exist");
assert.match(readme, /\| 올리브영 검색 \|/);
assert.match(readme, /\[올리브영 검색 가이드\]\(docs\/features\/olive-young-search\.md\)/);
assert.match(install, /--skill olive-young-search/);
assert.match(install, /npm install -g .* daiso/);
assert.match(roadmap, /올리브영 검색 스킬 출시/);
assert.match(sources, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/daiso/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/stores/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/products/);
assert.match(sources, /https:\/\/mcp\.aka\.page\/api\/oliveyoung\/inventory/);
});
test("olive-young install docs warn about intermittent public endpoint failures and direct users to retry or clone fallback", () => {
const install = read(path.join("docs", "install.md"));
const quickstart = findSection(install, "### `olive-young-search` upstream CLI quickstart");
assert.match(install, /olive-young-search/);
assert.match(install, /5xx\/503/);
assert.match(install, /재시도|retry/i);
assert.match(install, /clone fallback|git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/i);
assertOliveYoungCloneFallbackShorthand(quickstart, "olive-young install quickstart");
assertOliveYoungCloneFallbackCommands(quickstart, "olive-young install quickstart");
});
test("olive-young-search skill documents the upstream daiso CLI flow for stores, products, and inventory", () => {
const skillPath = path.join(repoRoot, "olive-young-search", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "olive-young-search.md"));
assert.ok(fs.existsSync(skillPath), "expected olive-young-search/SKILL.md to exist");
const skill = read(path.join("olive-young-search", "SKILL.md"));
const featureTop = findSection(featureDoc, "## 가장 중요한 규칙");
const featureFallback = findSection(featureDoc, "## 원본 저장소 clone fallback");
const skillFallback = findSection(skill, "## Fallback: clone the original repository and run the same CLI locally");
assert.match(skill, /^name: olive-young-search$/m);
assert.match(skill, /^description: .*올리브영.*매장.*상품.*재고.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /hmmhmmhm\/daiso-mcp/);
assert.match(doc, /https:\/\/github\.com\/hmmhmmhm\/daiso-mcp/);
assert.match(doc, /npm install -g daiso|npx --yes daiso|npx daiso/);
assert.match(doc, /git clone https:\/\/github\.com\/hmmhmmhm\/daiso-mcp\.git/);
assert.match(doc, /npm install/);
assert.match(doc, /npm run build/);
assert.match(doc, /MCP 서버를 .*직접 설치.*않고.*CLI/u);
assert.match(doc, /매장 검색/);
assert.match(doc, /상품 검색/);
assert.match(doc, /재고 확인/);
assert.match(doc, /\/api\/oliveyoung\/stores/);
assert.match(doc, /\/api\/oliveyoung\/products/);
assert.match(doc, /\/api\/oliveyoung\/inventory/);
assert.match(doc, /vendoring 하지 않/);
}
assertOliveYoungCloneFallbackShorthand(featureTop, "olive-young feature guide shorthand");
for (const fallbackDoc of [featureFallback, skillFallback]) {
assertOliveYoungCloneFallbackCommands(fallbackDoc, "olive-young clone fallback docs");
}
});
test("repository docs advertise the coupang-product-search skill", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -684,6 +855,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
});
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {
@ -998,6 +1170,19 @@ test("pack:dry-run includes the toss-securities workspace", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace used-car-price-search/);
});
test("used-car-price-search ships with a changeset for release automation", () => {
const changesetDir = path.join(repoRoot, ".changeset");
const changesetFiles = fs
.readdirSync(changesetDir)
.filter((name) => name.endsWith(".md"))
.map((name) => read(path.join(".changeset", name)));
assert.ok(
changesetFiles.some((doc) => /["']used-car-price-search["']:\s*(patch|minor|major)/.test(doc)),
"expected a changeset entry that releases used-car-price-search",
);
});
test("package-lock captures the toss-securities workspace metadata for npm ci", () => {
const packageLock = readJson("package-lock.json");
@ -1006,7 +1191,7 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
resolved: "packages/toss-securities",
link: true,
});
assert.equal(packageLock.packages["packages/toss-securities"].version, "0.1.0");
assert.equal(packageLock.packages["packages/toss-securities"].version, "0.2.0");
assert.equal(packageLock.packages["packages/toss-securities"].license, "MIT");
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
});
@ -1102,3 +1287,224 @@ test("korean-law-search skill keeps korean-law-mcp-first guidance while document
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-law-search")), false);
});
test("repository docs advertise the joseon-sillok-search skill and helper", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "joseon-sillok-search.md");
const featureDoc = read(path.join("docs", "features", "joseon-sillok-search.md"));
const skillPath = path.join(repoRoot, "joseon-sillok-search", "SKILL.md");
const skill = read(path.join("joseon-sillok-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/joseon-sillok-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected joseon-sillok-search/SKILL.md to exist");
assert.match(readme, /\| 조선왕조실록 검색 \|/);
assert.match(readme, /\[조선왕조실록 검색 가이드\]\(docs\/features\/joseon-sillok-search\.md\)/);
assert.match(install, /--skill joseon-sillok-search/);
assert.match(install, /python3 scripts\/sillok_search\.py --query "훈민정음" --king 세종 --year 1443/);
assert.match(skill, /sillok\.history\.go\.kr/);
assert.match(skill, /--king/);
assert.match(skill, /--year/);
assert.match(featureDoc, /python3 scripts\/sillok_search\.py --query "훈민정음"/);
assert.match(featureDoc, /1443/);
assert.match(featureDoc, /kda_12512030_002/);
assert.match(sources, /https:\/\/sillok\.history\.go\.kr/);
assert.match(sources, /https:\/\/sillok\.history\.go\.kr\/search\/searchResultList\.do/);
assert.match(roadmap, /조선왕조실록 검색 스킬 출시/);
});
test("joseon-sillok-search install payload includes the documented helper command", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "joseon-sillok-"));
const installedSkillPath = path.join(tempRoot, "joseon-sillok-search");
const bundledHelperPath = path.join(installedSkillPath, "scripts", "sillok_search.py");
try {
fs.cpSync(path.join(repoRoot, "joseon-sillok-search"), installedSkillPath, { recursive: true });
assert.ok(fs.existsSync(bundledHelperPath), "expected joseon-sillok-search/scripts/sillok_search.py to exist");
const helpText = childProcess.execFileSync("python3", ["scripts/sillok_search.py", "--help"], {
cwd: installedSkillPath,
encoding: "utf8",
});
assert.match(helpText, /Search Joseon Sillok records from sillok\.history\.go\.kr/);
assert.match(helpText, /--query/);
assert.match(helpText, /--king/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("repository docs advertise the real-estate-search skill and proxy-based approach", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "real-estate-search.md");
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
const skillPath = path.join(repoRoot, "real-estate-search", "SKILL.md");
const skill = read(path.join("real-estate-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/real-estate-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected real-estate-search/SKILL.md to exist");
assert.match(readme, /\| 한국 부동산 실거래가 조회 \|/);
assert.match(readme, /\[한국 부동산 실거래가 조회 가이드\]\(docs\/features\/real-estate-search\.md\)/);
assert.match(install, /--skill real-estate-search/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /\/v1\/real-estate\//);
assert.match(doc, /apartment\/trade|apartment\/rent/);
assert.match(doc, /region-code/);
assert.doesNotMatch(doc, /packages\/real-estate-search/);
assert.doesNotMatch(doc, /python-packages\/real-estate-search/);
}
for (const doc of [install]) {
assert.match(doc, /https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
assert.match(doc, /k-skill-proxy\.nomadamas\.org|hosted proxy/);
}
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /DATA_GO_KR_API_KEY/);
}
assert.match(sources, /real-estate-mcp: https:\/\/github\.com\/tae0y\/real-estate-mcp\/tree\/main/);
assert.match(roadmap, /한국 부동산 실거래가 조회 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("real-estate-search")),
"expected no repo workspace to be added for real-estate-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "real-estate-search")), false);
});
test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
const skill = read(path.join("real-estate-search", "SKILL.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/real-estate/);
assert.match(doc, /curl/);
assert.doesNotMatch(doc, /uv run/);
assert.doesNotMatch(doc, /codex mcp add/);
assert.doesNotMatch(doc, /Cloudflare Tunnel/i);
assert.doesNotMatch(doc, /launchd/i);
assert.doesNotMatch(doc, /docker compose/i);
}
});
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-spell-check.md");
const helperPath = path.join(repoRoot, "scripts", "korean_spell_check.py");
assert.equal(fs.existsSync(featureDocPath), true);
assert.equal(fs.existsSync(helperPath), true);
assert.match(readme, /\[한국어 맞춤법 검사 가이드\]\(docs\/features\/korean-spell-check\.md\)/);
assert.match(install, /python3 scripts\/korean_spell_check\.py/);
});
test("repository docs advertise the cheap-gas-nearby skill and Opinet key requirements", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
const featureDocPath = path.join(repoRoot, "docs", "features", "cheap-gas-nearby.md");
const skillPath = path.join(repoRoot, "cheap-gas-nearby", "SKILL.md");
assert.equal(fs.existsSync(featureDocPath), true);
assert.equal(fs.existsSync(skillPath), true);
assert.match(readme, /\| 근처 가장 싼 주유소 찾기 \|/);
assert.match(readme, /\[근처 가장 싼 주유소 찾기 가이드\]\(docs\/features\/cheap-gas-nearby\.md\)/);
assert.match(install, /--skill cheap-gas-nearby/);
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /주유소 가격|OPINET_API_KEY/);
assert.match(doc, /hosted proxy|proxy.*경유/);
}
assert.doesNotMatch(examplesSecrets, /^OPINET_API_KEY=replace-me$/m);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/user\/custapi\/openApiInfo\.do/);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/aroundAll\.do/);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/detailById\.do/);
assert.match(roadmap, /근처 가장 싼 주유소 찾기 스킬 출시/);
});
test("cheap-gas-nearby skill docs require location-first prompts and official Opinet surfaces", () => {
const skill = read(path.join("cheap-gas-nearby", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "cheap-gas-nearby.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /현재 위치를 알려주세요/);
assert.match(doc, /OPINET_API_KEY/);
assert.match(doc, /aroundAll\.do/);
assert.match(doc, /detailById\.do/);
assert.match(doc, /areaCode\.do/);
assert.match(doc, /휘발유|경유/);
assert.match(doc, /KATEC/);
assert.match(doc, /카카오맵|Kakao Map/);
}
});
test("repository docs advertise the han-river-water-level skill and rollout-pending proxy workflow", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "han-river-water-level.md");
const featureDoc = read(path.join("docs", "features", "han-river-water-level.md"));
const skillPath = path.join(repoRoot, "han-river-water-level", "SKILL.md");
const skill = read(path.join("han-river-water-level", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/han-river-water-level.md to exist");
assert.ok(fs.existsSync(skillPath), "expected han-river-water-level/SKILL.md to exist");
assert.match(readme, /\| 한강 수위 정보 조회 \|/);
assert.match(readme, /\[한강 수위 정보 가이드\]\(docs\/features\/han-river-water-level\.md\)/);
assert.match(install, /--skill han-river-water-level/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /\/v1\/han-river\/water-level/);
assert.match(doc, /stationName|station_code|stationCode/);
assert.match(doc, /수위|유량/);
assert.match(doc, /candidate_stations|ambiguous_station/);
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
}
assert.match(featureDoc, /HRFCO_OPEN_API_KEY/);
assert.match(skill, /기본적으로 `https:\/\/k-skill-proxy\.nomadamas\.org\/v1\/han-river\/water-level`/);
assert.doesNotMatch(featureDoc, /기본 hosted 조회:/);
for (const doc of [proxyDoc, proxyReadme]) {
assert.match(doc, /\/v1\/han-river\/water-level/);
assert.match(doc, /HRFCO_OPEN_API_KEY/);
assert.match(doc, /waterlevel\/info\.json/);
assert.match(doc, /waterlevel\/list\/10M/);
}
assert.match(setup, /한강 수위 정보 조회 \| 사용자 시크릿 불필요/);
assert.match(setup, /한강 수위.*기본 hosted p/i);
assert.match(security, /KSKILL_PROXY_BASE_URL.*서울 지하철.*route가 실제 배포된 proxy URL/);
assert.match(sources, /hrfco\.go\.kr\/web\/openapiPage\/reference\.do/);
assert.match(sources, /api\.hrfco\.go\.kr/);
assert.match(roadmap, /한강 수위 정보 조회 스킬 출시/);
});

View file

@ -0,0 +1,300 @@
import contextlib
import io
import json
import unittest
from scripts.korean_spell_check import (
SpellCheckIssue,
apply_page_corrections,
check_text,
extract_result_payload,
parse_args,
split_text_into_chunks,
)
SAMPLE_RESULTS_HTML = """<!DOCTYPE html>
<html>
<head></head>
<body>
<script type="text/javascript">
$(document).ready(function(){
data = [{"str":"아버지가방에들어가신다.","errInfo":[{"help":"철자 검사를 해 보니 이 어절은 분석할 수 없으므로 틀린 말로 판단하였습니다.<br/><br/>후보 어절은 이 철자 검사/교정기에서 띄어쓰기, 붙여쓰기, 음절 대치와 같은 교정 방법에 따라 수정한 결과입니다.","errorIdx":0,"correctMethod":3,"start":0,"errMsg":"","end":11,"orgStr":"아버지가방에들어가신다","candWord":"아버지가 방에 들어가신다"}],"idx":0}];
pageIdx = 0;
if(1){
totalPageCnt = 1;
}
data = eval(data);
});
</script>
</body>
</html>
"""
SAMPLE_NO_ISSUES_HTML = """<!DOCTYPE html>
<html>
<head></head>
<body>
<table id="tableMain">
<tr id="trMain">
<td id="tdBody" style="text-align: center;">맞춤법과 문법 오류를 찾지 못했습니다.<br></td>
</tr>
</table>
</body>
</html>
"""
class SplitTextIntoChunksTest(unittest.TestCase):
def test_prefers_paragraph_boundaries_before_falling_back(self):
text = "첫 문단입니다.\n\n둘째 문단입니다.\n\n셋째 문단입니다."
chunks = split_text_into_chunks(text, max_chars=15)
self.assertEqual(chunks, ["첫 문단입니다.\n\n", "둘째 문단입니다.\n\n", "셋째 문단입니다."])
self.assertEqual("".join(chunks), text)
def test_preserves_exact_blank_runs_and_indentation_when_rejoined(self):
text = "아버지가방에들어가신다.\n\n\n 왠지 않되요."
chunks = split_text_into_chunks(text, max_chars=15)
self.assertEqual("".join(chunks), text)
def test_handles_overlong_unit_with_trailing_separator(self):
text = "첫문장은조금길게씁니다. 둘째문장도길어요.\n\n다음 문단입니다."
chunks = split_text_into_chunks(text, max_chars=18)
self.assertEqual("".join(chunks), text)
def test_avoids_separator_only_chunks_when_a_paragraph_exactly_hits_the_limit(self):
text = "123456789012345\n\nabc"
chunks = split_text_into_chunks(text, max_chars=15)
self.assertEqual(chunks, ["123456789012345", "\n\nabc"])
class ExtractResultPayloadTest(unittest.TestCase):
def test_extracts_issue_rows_from_official_results_html(self):
pages = extract_result_payload(SAMPLE_RESULTS_HTML)
self.assertEqual(len(pages), 1)
self.assertEqual(pages[0]["str"], "아버지가방에들어가신다.")
self.assertEqual(pages[0]["errInfo"][0]["candWord"], "아버지가 방에 들어가신다")
def test_apply_page_corrections_uses_the_first_candidate(self):
pages = extract_result_payload(SAMPLE_RESULTS_HTML)
corrected = apply_page_corrections(pages[0])
self.assertEqual(corrected, "아버지가 방에 들어가신다.")
def test_returns_empty_pages_when_service_reports_no_issues(self):
self.assertEqual(extract_result_payload(SAMPLE_NO_ISSUES_HTML), [])
class CheckTextTest(unittest.TestCase):
def test_check_text_builds_chunked_issue_reports(self):
requested_texts = []
def fake_requester(chunk, *, strong_rules, timeout):
requested_texts.append((chunk, strong_rules, timeout))
payload = json.dumps(
[
{
"str": chunk,
"errInfo": [
{
"help": "철자 검사를 해 보니 이 어절은 분석할 수 없으므로 틀린 말로 판단하였습니다.<br/><br/>후보 어절은 이 철자 검사/교정기에서 띄어쓰기, 붙여쓰기, 음절 대치와 같은 교정 방법에 따라 수정한 결과입니다.",
"errorIdx": 0,
"correctMethod": 3,
"start": 0,
"errMsg": "",
"end": 11,
"orgStr": "아버지가방에들어가신다",
"candWord": "아버지가 방에 들어가신다",
}
],
"idx": 0,
}
],
ensure_ascii=False,
)
return f"""<!DOCTYPE html>
<html>
<head></head>
<body>
<script type="text/javascript">
$(document).ready(function(){{
data = {payload};
pageIdx = 0;
if(1){{
totalPageCnt = 1;
}}
data = eval(data);
}});
</script>
</body>
</html>
"""
report = check_text(
"아버지가방에들어가신다.\n\n아버지가방에들어가신다.",
max_chars=15,
requester=fake_requester,
throttle_seconds=0,
)
self.assertEqual(len(report["chunks"]), 2)
self.assertEqual(report["corrected_text"], "아버지가 방에 들어가신다.\n\n아버지가 방에 들어가신다.")
self.assertEqual(len(report["issues"]), 2)
self.assertIsInstance(report["issues"][0], SpellCheckIssue)
self.assertEqual(report["issues"][0].original, "아버지가방에들어가신다")
self.assertEqual(report["issues"][0].suggestions[0], "아버지가 방에 들어가신다")
self.assertEqual(requested_texts[0][0], "아버지가방에들어가신다.\n\n")
self.assertTrue(all(call[1] for call in requested_texts))
def test_check_text_preserves_blank_lines_when_payload_collapses_them(self):
html = """<!DOCTYPE html>
<html>
<body>
<script>
data = [{"str":"아버지가방에들어가신다.왠지 않되요.","errInfo":[
{"help":"띄어쓰기 교정","errorIdx":0,"correctMethod":3,"start":0,"errMsg":"","end":11,"orgStr":"아버지가방에들어가신다","candWord":"아버지가 방에 들어가신다"},
{"help":"활용형 교정","errorIdx":1,"correctMethod":3,"start":15,"errMsg":"","end":17,"orgStr":"않되요","candWord":"안 돼요"}
]}];
pageIdx = 0;
</script>
</body>
</html>
"""
report = check_text(
"아버지가방에들어가신다.\n\n왠지 않되요.",
max_chars=50,
requester=lambda chunk, *, strong_rules, timeout: html,
throttle_seconds=0,
)
self.assertEqual(report["corrected_text"], "아버지가 방에 들어가신다.\n\n왠지 안 돼요.")
self.assertEqual(report["chunks"][0]["corrected_text"], "아버지가 방에 들어가신다.\n\n왠지 안 돼요.")
def test_check_text_preserves_blank_lines_when_service_suggests_sentence_spacing(self):
html = """<!DOCTYPE html>
<html>
<body>
<script>
data = [{"str":"아버지가방에들어가신다.왠지 않되요.","errInfo":[
{"help":"띄어쓰기 교정","errorIdx":0,"correctMethod":3,"start":0,"errMsg":"","end":11,"orgStr":"아버지가방에들어가신다","candWord":"아버지가 방에 들어가신다"},
{"help":"문장 부호 뒤 띄어쓰기","errorIdx":1,"correctMethod":3,"start":11,"errMsg":"","end":14,"orgStr":".왠지","candWord":". 왠지"},
{"help":"활용형 교정","errorIdx":2,"correctMethod":1,"start":15,"errMsg":"","end":18,"orgStr":"않되요","candWord":"안 돼요"}
]}];
pageIdx = 0;
</script>
</body>
</html>
"""
report = check_text(
"아버지가방에들어가신다.\n\n왠지 않되요.",
max_chars=50,
requester=lambda chunk, *, strong_rules, timeout: html,
throttle_seconds=0,
)
self.assertEqual(report["corrected_text"], "아버지가 방에 들어가신다.\n\n왠지 안 돼요.")
def test_check_text_preserves_indent_and_triple_blank_lines_for_file_style_input(self):
html = """<!DOCTYPE html>
<html>
<body>
<script>
data = [{"str":"아버지가방에들어가신다.왠지 않되요.","errInfo":[
{"help":"띄어쓰기 교정","errorIdx":0,"correctMethod":3,"start":0,"errMsg":"","end":11,"orgStr":"아버지가방에들어가신다","candWord":"아버지가 방에 들어가신다"},
{"help":"활용형 교정","errorIdx":1,"correctMethod":3,"start":15,"errMsg":"","end":17,"orgStr":"않되요","candWord":"안 돼요"}
]}];
pageIdx = 0;
</script>
</body>
</html>
"""
report = check_text(
"아버지가방에들어가신다.\n\n\n 왠지 않되요.",
max_chars=50,
requester=lambda chunk, *, strong_rules, timeout: html,
throttle_seconds=0,
)
self.assertEqual(report["corrected_text"], "아버지가 방에 들어가신다.\n\n\n 왠지 안 돼요.")
def test_check_text_keeps_separator_layout_when_service_merges_spacing_across_boundary(self):
html = """<!DOCTYPE html>
<html>
<body>
<script>
data = [{"str":"아버지가방에들어가신다. 왠지 않되요.","errInfo":[
{"help":"공백 교정","errorIdx":0,"correctMethod":4,"start":0,"errMsg":"","end":16,"orgStr":"아버지가방에들어가신다. 왠지","candWord":"아버지가 방에 들어가신다. 왠지"},
{"help":"활용형 교정","errorIdx":1,"correctMethod":1,"start":17,"errMsg":"","end":20,"orgStr":"않되요","candWord":"안 돼요"}
]}];
pageIdx = 0;
</script>
</body>
</html>
"""
report = check_text(
"아버지가방에들어가신다.\n\n\n 왠지 않되요.",
max_chars=50,
requester=lambda chunk, *, strong_rules, timeout: html,
throttle_seconds=0,
)
self.assertEqual(report["corrected_text"], "아버지가 방에 들어가신다.\n\n\n 왠지 안 돼요.")
def test_check_text_accepts_no_issue_chunks_before_a_later_corrected_chunk(self):
error_html = """<!DOCTYPE html>
<html>
<body>
<script>
data = [{"str":" 아버지가방에들어가신다.","errInfo":[
{"help":"띄어쓰기 교정","errorIdx":0,"correctMethod":3,"start":0,"errMsg":"","end":13,"orgStr":"아버지가방에들어가신다","candWord":"아버지가 방에 들어가신다"}
]}];
pageIdx = 0;
</script>
</body>
</html>
"""
chunks = iter([SAMPLE_NO_ISSUES_HTML, SAMPLE_NO_ISSUES_HTML, error_html])
text = "첫 문장은 조금 길겠습니다. 둘째 문장도 길어요.\n\n\n 아버지가방에들어가신다.\n"
report = check_text(
text,
max_chars=18,
requester=lambda chunk, *, strong_rules, timeout: next(chunks),
throttle_seconds=0,
)
self.assertEqual(report["meta"]["chunk_count"], 3)
self.assertEqual(
report["corrected_text"],
"첫 문장은 조금 길겠습니다. 둘째 문장도 길어요.\n\n\n 아버지가 방에 들어가신다.\n",
)
self.assertEqual(len(report["issues"]), 1)
class ParseArgsTest(unittest.TestCase):
def test_rejects_non_positive_max_chars(self):
for value in ("0", "-1"):
with self.subTest(value=value):
with contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as ctx:
parse_args(["--text", "테스트", "--max-chars", value])
self.assertNotEqual(ctx.exception.code, 0)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,341 @@
import contextlib
import io
import ssl
import types
import unittest
from unittest import mock
from scripts.sillok_search import (
ArticleDetail,
SearchReport,
SearchResult,
build_http_client,
build_opener,
filter_results,
fetch_text,
parse_args,
parse_detail_page,
parse_result_title_metadata,
parse_search_results,
search_sillok,
)
SAMPLE_SEARCH_HTML = """<!DOCTYPE html>
<html lang=\"ko\">
<body>
<input type=\"hidden\" id=\"totalCount\" name=\"totalCount\" value=\"21\"/>
<input type=\"hidden\" id=\"countK\" name=\"countK\" value=\"11\"/>
<p class=\"result-text\">검색어 <strong>훈민정음</strong> / 검색결과 <strong>21</strong>개</p>
<div class=\"result-cate02\">
<div class=\"cate-area\">
<div class=\"item-wrap\">
<a href=\"javascript:searchCategory('');\" class=\"cate-item active\">전체 (11)</a>
<a href=\"javascript:searchCategory('세종실록');\" class=\"cate-item\">세종 (5)</a>
<a href=\"javascript:searchCategory('정조실록');\" class=\"cate-item\">정조 (1)</a>
</div>
</div>
</div>
<div class=\"result-list\">
<div class=\"result-box\">
<a href=\"javascript:goView('kda_12512030_002', 0);\" class=\"subject\">1. 세종실록 102권, 세종 25년 12월 30일 경술 2번째기사 / <span class='s_keyword'>훈민정음</span>을 창제하다</a>
<p class=\"text\">이달에 임금이 친히 언문 28자를 지었다.</p>
</div>
<div class=\"result-box\">
<a href=\"javascript:goView('kva_10707018_002', 10);\" class=\"subject\">2. 정조실록 16권, 정조 7년 7월 18일 정미 2번째기사 / 수레·벽돌의 사용 등 중국의 문물에 대한 홍양호의 상소문</a>
<p class=\"text\">수레·벽돌의 사용, 당나귀·양의 목축 등 중국의 문물에 대한 상소문이다.</p>
</div>
</div>
</body>
</html>
"""
SAMPLE_DETAIL_HTML = """<!DOCTYPE html>
<html lang=\"ko\">
<body>
<div class=\"detail-view\">
<div class=\"title-head\">
<div class=\"title\">
<p class=\"date\">세종실록102권, 세종 25년 12월 30일 경술 2/2 기사 <span>/ 1443년 명 정통(正統) 8년</span></p>
<h3>훈민정음을 창제하다</h3>
</div>
</div>
<div class=\"view-area\">
<div class=\"view-item left\">
<h4 class=\"view-title\">국역</h4>
<div class=\"view-text\">이달에 임금이 친히 언문(諺文) 28자를 지었다.</div>
<ul>
<li class=\"view_font01\">【태백산사고본】 33책 102권 42장 A면 【국편영인본】 4책 533면</li>
<li class=\"view_font02\">〖분류〗 어문학-어학(語學)</li>
</ul>
</div>
<div class=\"view-item right\">
<h4 class=\"view-title\">원문</h4>
<div class=\"view-text\">○是月, 上親制諺文二十八字。</div>
</div>
</div>
</div>
</body>
</html>
"""
SAMPLE_DETAIL_WITH_FOOTER_HTML = """<!DOCTYPE html>
<html lang=\"ko\">
<body>
<div class=\"detail-view\">
<div class=\"title-head\">
<div class=\"title\">
<p class=\"date\">세종실록102권, 세종 25년 12월 30일 경술 2/2 기사 <span>/ 1443년 명 정통(正統) 8년</span></p>
<h3>훈민정음을 창제하다</h3>
</div>
</div>
<div class=\"view-area\">
<div class=\"view-item left\">
<h4 class=\"view-title\">국역</h4>
<div class=\"view-text\">
이달에 임금이 친히 언문(諺文) 28자를 지었다.<br/>
태백산사고본 33 102 42 A면국편영인본 4 533<br/>
분류어문학-어학(語學)<br/>
세종대왕기념사업회
</div>
<ul>
<li class=\"view_font02\">〖분류〗 어문학-어학(語學)</li>
</ul>
</div>
<div class=\"view-item right\">
<h4 class=\"view-title\">원문</h4>
<div class=\"view-text\">
是月, 上親制諺文二十八字<br/>
世宗莊憲大王實錄卷第一百二終<br/>
태백산사고본 33 102 42 A면국편영인본 4 533<br/>
분류어문학-어학(語學)
</div>
</div>
</div>
</div>
</body>
</html>
"""
class ParseResultTitleMetadataTest(unittest.TestCase):
def test_parses_regnal_and_gregorian_year_from_standard_title(self):
metadata = parse_result_title_metadata(
"세종실록 102권, 세종 25년 12월 30일 경술 2번째기사 / 훈민정음을 창제하다"
)
self.assertEqual(metadata.king, "세종")
self.assertEqual(metadata.regnal_year, 25)
self.assertEqual(metadata.gregorian_year, 1443)
self.assertEqual(metadata.article_title, "훈민정음을 창제하다")
def test_treats_accession_year_as_regnal_year_one(self):
metadata = parse_result_title_metadata(
"문종실록 5권, 문종 즉위년 12월 17일 정해 7번째기사 / 정음청에 보관하던 주자를 주자소에 돌려 주게 하다"
)
self.assertEqual(metadata.king, "문종")
self.assertEqual(metadata.regnal_year, 1)
self.assertEqual(metadata.gregorian_year, 1450)
class ParseSearchResultsTest(unittest.TestCase):
def test_extracts_categories_and_result_items(self):
report = parse_search_results(SAMPLE_SEARCH_HTML, query="훈민정음", search_type="k")
self.assertEqual(report.total_results, 21)
self.assertEqual(report.type_count, 11)
self.assertEqual([item.label for item in report.categories], ["전체", "세종", "정조"])
self.assertEqual(report.categories[1].count, 5)
self.assertEqual(len(report.items), 2)
self.assertEqual(report.items[0].article_id, "kda_12512030_002")
self.assertEqual(report.items[0].url, "https://sillok.history.go.kr/id/kda_12512030_002")
self.assertEqual(report.items[0].king, "세종")
self.assertEqual(report.items[0].gregorian_year, 1443)
self.assertIn("언문", report.items[0].summary)
def test_filters_by_king_and_year(self):
report = parse_search_results(SAMPLE_SEARCH_HTML, query="훈민정음", search_type="k")
filtered = filter_results(report.items, king="세종", year=1443)
self.assertEqual(len(filtered), 1)
self.assertEqual(filtered[0].article_id, "kda_12512030_002")
class ParseDetailPageTest(unittest.TestCase):
def test_extracts_translated_original_and_classification(self):
detail = parse_detail_page(SAMPLE_DETAIL_HTML, article_id="kda_12512030_002")
self.assertEqual(detail.title, "훈민정음을 창제하다")
self.assertEqual(detail.header, "세종실록102권, 세종 25년 12월 30일 경술 2/2 기사 / 1443년 명 정통(正統) 8년")
self.assertEqual(detail.translated_text, "이달에 임금이 친히 언문(諺文) 28자를 지었다.")
self.assertEqual(detail.original_text, "○是月, 上親制諺文二十八字。")
self.assertEqual(detail.classification, "어문학-어학(語學)")
def test_strips_bibliographic_and_copyright_footer_from_article_text(self):
detail = parse_detail_page(SAMPLE_DETAIL_WITH_FOOTER_HTML, article_id="kda_12512030_002")
self.assertEqual(detail.translated_text, "이달에 임금이 친히 언문(諺文) 28자를 지었다.")
self.assertEqual(detail.original_text, "○是月, 上親制諺文二十八字。 世宗莊憲大王實錄卷第一百二終")
self.assertEqual(detail.classification, "어문학-어학(語學)")
class NetworkingRegressionTest(unittest.TestCase):
def test_build_http_client_keeps_urllib_opener_available_when_requests_is_installed(self):
fake_requests = mock.Mock()
with (
mock.patch("scripts.sillok_search.requests", fake_requests),
mock.patch("scripts.sillok_search.build_opener", return_value="opener") as build_opener_mock,
):
opener = build_http_client()
self.assertEqual(opener, "opener")
build_opener_mock.assert_called_once_with()
def test_build_opener_keeps_default_tls_verification(self):
fake_context = mock.Mock()
fake_context.check_hostname = True
fake_context.verify_mode = ssl.CERT_REQUIRED
with (
mock.patch("scripts.sillok_search.ssl.create_default_context", return_value=fake_context),
mock.patch("scripts.sillok_search.urllib.request.HTTPCookieProcessor", return_value="cookie-processor"),
mock.patch(
"scripts.sillok_search.urllib.request.HTTPSHandler",
side_effect=lambda *, context: ("https-handler", context),
),
mock.patch("scripts.sillok_search.urllib.request.build_opener", return_value="opener") as build_opener_mock,
):
opener = build_opener()
self.assertEqual(opener, "opener")
self.assertTrue(fake_context.check_hostname)
self.assertEqual(fake_context.verify_mode, ssl.CERT_REQUIRED)
build_opener_mock.assert_called_once_with("cookie-processor", ("https-handler", fake_context))
def test_fetch_text_keeps_requests_tls_verification_enabled(self):
response = mock.Mock()
response.text = "<html></html>"
response.raise_for_status.return_value = None
fake_requests = mock.Mock()
fake_requests.post.return_value = response
with mock.patch("scripts.sillok_search.requests", fake_requests):
html_text = fetch_text(
None,
"https://sillok.history.go.kr/search/searchResultList.do",
data={"topSearchWord": "훈민정음"},
)
self.assertEqual(html_text, "<html></html>")
self.assertNotIn("verify", fake_requests.post.call_args.kwargs)
def test_fetch_text_falls_back_to_urllib_when_requests_transport_fails(self):
class TransportError(Exception):
pass
class HttpError(TransportError):
pass
response = mock.MagicMock()
response.read.return_value = "<html>fallback</html>".encode("utf-8")
response.__enter__.return_value = response
opener = mock.Mock()
opener.open.return_value = response
fake_requests = mock.Mock()
fake_requests.post.side_effect = TransportError("Connection aborted")
fake_requests.exceptions = types.SimpleNamespace(RequestException=TransportError, HTTPError=HttpError)
with mock.patch("scripts.sillok_search.requests", fake_requests):
html_text = fetch_text(
opener,
"https://sillok.history.go.kr/search/searchResultList.do",
data={"topSearchWord": "훈민정음"},
timeout=20,
)
self.assertEqual(html_text, "<html>fallback</html>")
opener.open.assert_called_once()
class SearchSillokRegressionTest(unittest.TestCase):
def test_search_continues_to_later_pages_for_filtered_matches(self):
non_matching_items = [
SearchResult(
article_id=f"page1_{index}",
url=f"https://sillok.history.go.kr/id/page1_{index}",
title=f"정조실록 {index} / 다른 기사",
article_title="다른 기사",
summary="page 1",
king="정조",
regnal_year=7,
gregorian_year=1783,
)
for index in range(10)
]
matching_item = SearchResult(
article_id="kda_12512030_002",
url="https://sillok.history.go.kr/id/kda_12512030_002",
title="세종실록 102권, 세종 25년 12월 30일 / 훈민정음을 창제하다",
article_title="훈민정음을 창제하다",
summary="세종 page 2",
king="세종",
regnal_year=25,
gregorian_year=1443,
)
reports_by_page = {
1: SearchReport(query="훈민정음", search_type="k", total_results=21, type_count=11, categories=[], items=non_matching_items),
2: SearchReport(query="훈민정음", search_type="k", total_results=21, type_count=11, categories=[], items=[matching_item]),
}
detail = ArticleDetail(
article_id="kda_12512030_002",
url="https://sillok.history.go.kr/id/kda_12512030_002",
header="세종실록102권, 세종 25년 12월 30일 경술 2/2 기사 / 1443년 명 정통(正統) 8년",
title="훈민정음을 창제하다",
translated_text="이달에 임금이 친히 언문(諺文) 28자를 지었다.",
original_text="○是月, 上親制諺文二十八字。",
classification="어문학-어학(語學)",
)
page_calls: list[int] = []
def fake_fetch_search_page(_opener, *, query, search_type, page_index, timeout):
self.assertEqual(query, "훈민정음")
self.assertEqual(search_type, "k")
self.assertEqual(timeout, 7)
page_calls.append(page_index)
return reports_by_page[page_index]
with (
mock.patch("scripts.sillok_search.build_http_client", return_value=object()),
mock.patch("scripts.sillok_search.fetch_search_page", side_effect=fake_fetch_search_page),
mock.patch("scripts.sillok_search.fetch_detail_page", return_value=detail) as fetch_detail_page_mock,
):
report = search_sillok("훈민정음", king="세종", year=1443, limit=1, timeout=7)
self.assertEqual(page_calls, [1, 2])
self.assertEqual(report["returned_count"], 1)
self.assertEqual(report["items"][0]["article_id"], "kda_12512030_002")
self.assertEqual(report["items"][0]["detail"]["classification"], "어문학-어학(語學)")
fetch_detail_page_mock.assert_called_once()
class ParseArgsTest(unittest.TestCase):
def test_accepts_keyword_and_optional_filters(self):
args = parse_args(["--query", "훈민정음", "--king", "세종", "--year", "1443", "--limit", "3"])
self.assertEqual(args.query, "훈민정음")
self.assertEqual(args.king, "세종")
self.assertEqual(args.year, 1443)
self.assertEqual(args.limit, 3)
def test_rejects_non_positive_year(self):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit):
parse_args(["--query", "훈민정음", "--year", "0"])
if __name__ == "__main__":
unittest.main()

View file

@ -38,6 +38,7 @@ done < <(
find "$root" -mindepth 1 -maxdepth 1 -type d \
! -name .git \
! -name .github \
! -name .claude \
! -name .omx \
! -name .changeset \
! -name docs \