Release: Merge dev into main (#163)

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

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

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

* Help users find nearby public restrooms from Korean location queries

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

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

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

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

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

* Keep nearby restroom lookups resilient to flaky Kakao place panels

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

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

* Keep the Hola Poke contract claims aligned with verified coverage

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

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

* Document the restroom distance-cap contract with regression coverage

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

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

* Expose KRX partial failures instead of misreporting stock lookups

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

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

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

* Adopt kordoc for the hwp skill workflow

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

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

* Prevent degraded stock search outages from sticking in cache

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

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

* Align HWP docs with the published kordoc surface

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

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

* Keep HWP docs runnable against the published kordoc package

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

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

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

* Add nationwide scholarship search skill workflow

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

* Fix scholarship skill validation in CI

* Trigger GitHub PR diff refresh after dev rebase on main

* Fix scholarship helper status handling and test coverage

* Use KST as scholarship helper default date basis

* Rename scholarship skill display name

---------

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

* Feature/#121 (#127)

* Recover KakaoTalk mac skill auth when upstream user_id detection fails

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

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

* Protect the KakaoTalk helper's safe recovery path

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

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

* Lock the helper CLI surface against accidental regressions

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

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

* Honor explicit Kakao auth recovery overrides

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

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

* Feature/#129 (#131)

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

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

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

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

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

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

* Add Naver Shopping price comparison skill

* Use Naver Shopping BFF fallback

* Fix naver shopping BFF page and sort fallback

* Clarify Naver OpenAPI review sort fallback

* Add library book search skill

* Add Data4Library route regression coverage

* Fix Data4Library book-exists ISBN-10 handling

* Refactor Coupang skill to retention MCP layer

* Add Coupang MCP wrapper follow-up coverage

* Clarify Coupang wrapper init guidance

* Document Coupang MCP init examples

* Add parking lot search skill

* Add korean-privacy-terms skill regression tests

* Add korean-privacy-terms thin-wrapper skill

* Document korean-privacy-terms skill across repo docs

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

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

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

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

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

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

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

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

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

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

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

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

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

Two related issues surfaced while investigating issue #148:

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

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

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

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

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

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

* Document Coupang hosted fallback contract and affiliate disclosure

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

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

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

Refs: #134

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

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

Remove the dead recommendation and lock in the working configuration:

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

* Add lh-notice-search skill and /v1/lh-notice/{search,detail} proxy routes

Wraps the official data.go.kr LH (Korea Land & Housing Corporation) 청약
공고 Open API (B552555/lhLeaseNoticeInfo1/*) so agents can look up LH
임대/분양/주거복지/토지/상가 공고 by region, status, category, keyword,
and notice ID without asking users for a ServiceKey. Reuses the shared
DATA_GO_KR_API_KEY the proxy already manages; users see '불필요'.

Adapter handles both the LH-specific [CMN, dsList] JSON envelope and the
standard data.go.kr <OpenAPI_ServiceResponse> XML error envelope; refuses
to cache failure responses so transient upstream errors self-heal.

Closes #145.

* Document LH extractNoticeEnvelope success-code accept-list as deliberate

Per review note #4 on PR #158, extractNoticeEnvelope accepts four upstream
CMN.CODE values ("SUCCESS", "0", "00", "000") and three header.resultCode
values ("0", "00", "000") as success. This is deliberate: the data.go.kr
platform has surfaced different forms across catalog eras, and a future
normalization that flips SUCCESS to a numeric form must not regress into
502'ing otherwise-valid responses.

- Add an inline comment above the array-envelope success-code check in
  src/lh-notice.js explaining why the accept-list is NOT redundant.
- Add regression tests in test/lh-notice.test.js that explicitly exercise
  each accepted success code (SUCCESS/0/00/000 for array envelope; 0/00/000
  for object envelope) so a future refactor cannot silently collapse the
  accept-list.
- Add a paired rejection test that numeric-looking non-success codes like
  "22" and "10" still raise as upstream_error, disambiguating the
  accept-list from a blanket 'any numeric string passes' rule.

Test count: lh-notice.test.js 30 -> 38 (all pass); npm run ci exits 0.

* Pin LH /v1/lh-notice/detail failure-not-cached contract with regression test

Round 2 review noted that /v1/lh-notice/detail failure-not-cached
behavior was only verified via manual QA, while /search had an
explicit automated regression test.

This adds an equivalent automated test for /detail that:
- fails upstream once (XML SERVICE_KEY error, upstream_code=30)
- confirms first call returns 502 with cache.hit=false
- switches upstream to success and retries the same URL
- confirms second call returns 200 with cache.hit=false (failure was
  NOT cached, retry hit upstream again)
- sabotages upstream back to failing and verifies the third call
  serves the previously-cached success (cache.hit=true, no new fetch)

Verified the test genuinely catches regressions by temporarily
monkey-patching the detail route to cache error payloads — the test
correctly fails in that sabotaged state and passes when the route is
correct. Full server.test.js suite goes from 95 to 96 tests, all pass.

* Document LH /detail test pins both cache-protection layers

Adds a 12-line header comment to the 'lh-notice detail does not cache
upstream XML auth errors so retries self-heal' test in server.test.js
naming the two cache-protection layers it pins:

  (a) the early-return catch block in the route handler (no cache.set
      on upstream failure), and
  (b) the isFailureResponse() guard inside cache.set (refuses any
      payload with .error set).

Points future maintainers to the independent sabotage audit in PR #158
Round 3 review that proved bypassing either layer alone makes the
State 2 self-heal assertion fail, and cross-links the sibling /search
failure-not-cached test for symmetric coverage.

Addresses the Round 3 non-blocking observation #2 nice-to-have.
Test-only, comment-only: +12 lines, 0 source changes, 0 behavior
changes, 0 doc changes, 0 changeset changes. server.test.js remains
96/96, lh-notice.test.js remains 38/38, full proxy workspace 184/184.

* Add naver-news-search skill and /v1/naver-news/search proxy route

Closes #143. Proxies the official Naver Search Open API news endpoint
(openapi.naver.com/v1/search/news.json) through k-skill-proxy so users do
not need to issue their own Naver Client ID/Secret. Reuses the existing
NAVER_SEARCH_CLIENT_ID/NAVER_SEARCH_CLIENT_SECRET that naver-shopping already
consumes, since the Naver Developer application enables the 'Search' scope
covering both news and shopping.

Implementation details:
- src/naver-news.js normalizes q/display/start/sort, builds the official URL,
  calls upstream with X-Naver-Client-Id/Secret headers, and parses the JSON
  response into rank/title/description/link/original_link/pub_date items.
- Strips <b> highlight tags and decodes HTML entities in title/description
  using zero-width replacement so compound Korean words like '주식형' are
  preserved (not split into '주식 형').
- Parses RFC822 pubDate into pub_date_iso (ISO-8601 UTC) for clients.
- Deduplicates items by normalized link; drops entries missing title/link.
- Returns 503 upstream_not_configured when proxy keys are absent (no public
  BFF fallback exists for news like it does for shopping, so keys are
  required).
- Failure responses are not cached (failure-aware cache layer).
- Exposes naverNewsApiConfigured on /health.

14 new tests in test/naver-news.test.js cover query validation, URL
building, payload normalization (HTML stripping, entity decoding,
deduplication, missing-field tolerance), plus Fastify integration tests
for 200/400/401/429/500/503 paths, cache hit/miss, header wiring, and
the health flag.

* Add rhwp-edit and rhwp-advanced skills with k-skill-rhwp CLI

Splits HWP handling into three focused skills per issue #155:

- hwp (kept): kordoc-based read/convert (Markdown, JSON, diffing, form
  fields, Markdown->HWPX). Description narrowed to 'read-only' to make
  the routing policy explicit.
- rhwp-edit (new): HWP binary editing via new k-skill-rhwp npm package
  that wraps the @rhwp/core WASM bindings as CLI subcommands: info,
  list-paragraphs, search, insert-text, delete-text, replace-all,
  create-table, set-cell-text, create-blank, and render.
- rhwp-advanced (new): guidance for the upstream Rust rhwp CLI
  (export-svg --debug-overlay, dump, dump-pages, ir-diff, thumbnail,
  convert) for layout debugging, IR inspection, version comparison,
  and read-only-document unlocking.

The new k-skill-rhwp package under packages/ ships a Node.js 18+ CLI
and library that round-trips HWP 5.x documents entirely in-process; no
Rust toolchain is required. It auto-installs the WASM-required
globalThis.measureTextWidth shim for headless Node, and all editing
subcommands always write to a distinct output path so the source file
is never mutated. HWPX save remains disabled per the upstream rhwp
#196 data-safety gate; HWPX input is accepted but output is written as
HWP 5.x.

Includes 24 node:test cases covering init, round-trip insertText,
replaceAll, createTable + setCellText, deleteText, searchText,
listParagraphs, renderPage (SVG/HTML), and full CLI arg-parse +
end-to-end round-trip through the CLI layer.

Wires README feature table (3 rows for hwp / rhwp-edit / rhwp-advanced),
docs/install.md optional-install list, docs/roadmap.md (marks HWP
advanced editing as shipped while keeping Windows/security-module
automation out of scope), docs/sources.md (adds rhwp upstream, CLI
source, @rhwp/core, @rhwp/editor, and rhwp #196 references), and the
root pack:dry-run script. Adds a Changesets entry for k-skill-rhwp
minor.

Closes #155.

*  feat: add k-dart skill for DART OpenAPI financial disclosures (#147)

*  feat: add k-dart skill for DART OpenAPI financial disclosures

금감원 전자공시시스템(DART) 14개 endpoint 조회 스킬 추가.
공시검색, 기업개황, 재무제표, 배당, 증자/감자, 전환사채, 소송 등.
API_K_DART 환경변수로 직접 호출하며 프록시 불필요.

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

* 📝 docs(k-dart): remove redundant korean-stock-search dependency

corpCode.xml 자체에 회사명·종목코드·고유번호가 모두 포함되어 있으므로
korean-stock-search 스킬 연계 절차 제거

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

* 📝 docs: add k-dart to README feature table

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

* 📝 docs: add k-dart feature guide and fix README link format

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

* 🐛 fix(k-dart): correct status code 013, remove invalid corp_name filter, update daily limit

3개 critical 정확성 오류 수정:

1. 상태코드 013은 "조회된 데이터 없음"이며 "접근 권한 없음"이 아님 (012=접근 불가 IP).
   상태코드 표를 공식 명세 기준으로 재정리하고 누락된 014/021 코드 추가.
2. list.json은 corp_name 파라미터를 검색 필터로 지원하지 않음. SKILL.md의
   잘못된 진술과 corp_name을 사용한 misleading example을 제거하고, corp_code
   확보 절차를 거치도록 명시.
3. DART 일일 한도는 키당 10,000건이 아닌 20,000건이며 분당 약 1,000회
   throttle도 별도로 존재함. SKILL.md 및 docs/features/k-dart.md 모두 정정.

추가로 status: "013" 발생 시 사용자 안내 정책을 Response policy에 추가하고,
오픈API 이용현황 페이지 링크를 Notes에 추가함.

* 🐛 fix(k-dart): correct pifricDecsn endpoint, list.json corp_code optional, add empSttus, soften throttle claim

Codex adversarial review에서 식별된 4건의 추가 정확성 이슈 수정:

1. endpoint #8 유무상증자 결정이 잘못된 API에 연결됨. piicDecsn.json은
   유상증자 결정 (apiId=2020023)이며, 유무상증자 결정은 pifricDecsn.json
   (apiId=2020025)이 맞음. endpoint를 정정하고 piicDecsn (유상증자) 및
   fricDecsn (무상증자)와의 차이를 주의문으로 추가.

2. list.json의 corp_code 는 사실 선택사항이며, 미지정 시 검색 기간이
   3개월 이내로 제한될 뿐임. 이전 commit의 "corp_code 필수" 표현을
   정정하고, 두 가지 호출 패턴(corp_code 지정/미지정)을 Example
   requests에 모두 추가.

3. "분당 약 1,000회 throttle"은 공식 공개 가이드에 근거 없음
   (apiUsageStatusView.do 는 로그인 게이트). 공식 가이드가 명시한
   "일반적으로 20,000건 이상 요청 시 020 발생"만 유지하고 분당
   throttle 주장을 제거. 상태코드 표·Response policy도 일관되게 정리.

4. docs/features/k-dart.md가 "직원 현황" 기능을 광고하지만 SKILL.md
   에는 endpoint가 누락됨. empSttus.json (apiGrpCd=DS002,
   apiId=2019011)을 endpoint #8로 추가하고 example도 함께 등록.
   기존 endpoint 9~14는 10~15로 재번호.

* 🐛 fix(k-dart): align list.json signature and 020 caveat with official spec

Codex 2nd-round review에서 식별된 정확성 이슈 2건 수정:

1) list.json 요청 인자 signature가 공식 가이드(DS001/2019001)와 정확히
   일치하도록 재작성. crtfc_key 외 모든 파라미터가 선택사항임을 분명히
   하고, 각 파라미터의 default 동작과 pblntf_ty 값(A/B/C/D/E)도 명시.
   "corp_code 지정 시 기간 제한 없음" 표현은 공식 가이드가 보장하지
   않으므로 제거. corp_name이 공식 파라미터에 "존재하지 않는다"는
   사실로 수정 (이전: "지원하지 않는다").
   "corp_code 미지정 시 3개월 제한"은 외부 사용 사례에서 관찰된
   동작으로 약화 (공식 가이드에 별도 명시 없음).

2) 020 (요청 제한 초과) 안내가 일일 20,000건 cap 으로 너무 단정적
   해석되던 표현을 공식 메시지 그대로 보존: "일반적으로 20,000건
   이상 요청 시 발생하며, 키별로 별도 한도가 설정된 경우 다른
   임계치에서도 동일 코드가 반환될 수 있음". 상태코드 표·Response
   policy·Notes·docs/features/k-dart.md 모두 일관되게 정정.

* 🐛 fix(k-dart): mirror official Korean DS001/2019001 list.json spec exactly

Codex 3rd-round review에서 식별된 잔존 정확성 이슈 수정.

영어 가이드(DE001/AE00001)와 한국어 가이드(DS001/2019001)가 list.json
필수여부에서 다르게 표기되어 있어 이전 commit이 영어 가이드를 따랐으나,
한국어 공식 가이드를 직접 확인한 결과(opendart.fss.or.kr/guide/detail.do
?apiGrpCd=DS001&apiId=2019001) 다음이 한국어 공식 spec임을 확인:

- bgn_de, end_de는 Y(필수) (기본값은 명시되어 있으나 표기상 필수)
- corp_code 미지정 시 검색기간 3개월 제한은 공식 spec에 명시된 룰
  (외부 사용 사례 관찰이 아님)
- pblntf_ty는 A~J 전체 enum (정기공시/주요사항보고/발행공시/지분공시/
  기타공시/외부감사관련/펀드공시/자산유동화/거래소공시/공정위공시)
- page_count 기본값 10, 최대값 100
- corp_cls 복수 조건 불가
- last_reprt_at, sort, sort_mth 각 default 동작 명시

list.json 섹션을 공식 가이드 표와 1:1 일치하는 마크다운 표로 재작성.
3개월 제한 표현을 "외부 사례"에서 "공식 spec"으로 정정. Response policy
에 잔존하던 corp_name "지원하지 않는다" 표현도 "공식 파라미터에 존재하지
않는다"로 통일하여 #1 endpoint 섹션과 일관성 확보. docs/features/k-dart.md
도 동일하게 정정.

* 🐛 fix(k-dart): make list.json table 1:1 mirror of DS001/2019001 + unify corp_name wording

Codex 4th-round review가 식별한 잔존 이슈 2건 마무리.

1) list.json 파라미터 표를 공식 가이드 행 순서 그대로(crtfc_key,
   corp_code, bgn_de, end_de, last_reprt_at, pblntf_ty,
   pblntf_detail_ty, corp_cls, sort, sort_mth, page_no, page_count)
   재정리하고 공식 표의 모든 컬럼(요청키/명칭/타입/필수여부/값설명)을
   포함. page_no(1~n) / page_count(1~100, 기본10, 최대100) 범위
   값을 공식 표 그대로 표기. pblntf_detail_ty 값설명도 공식 표
   그대로 "(※ 상세 유형 참조: pblntf_detail_ty)"로 두고, 자주 쓰는
   코드 예시(A001/B001/F001/D001)는 표 아래 별도 단락으로 분리해
   표의 1:1 mirror 성격을 유지.

2) corp_name 관련 canonical 문장 "공식 요청 파라미터 표에
   corp_name 은 존재하지 않는다" 를 다음 3곳 모두 verbatim 일치
   시킴 (이전 commit에서 SKILL.md는 '않는다', docs/features는
   '않음' 으로 어미 차이가 잔존했음):
   - k-dart/SKILL.md #1 endpoint 섹션 주의문
   - k-dart/SKILL.md Response policy
   - docs/features/k-dart.md 에러/제약 섹션

* 🐛 fix(k-dart): unify corp_name canonical sentence verbatim + soften list.json table claim

Codex 5th-round review가 식별한 fine-grained 이슈 마무리.

1) corp_name canonical 문장을 self-contained 형태로 재작성하여
   3곳 모두 byte-for-byte 동일하게 통일:
   "DART OpenAPI list.json 의 공식 요청 파라미터 표에 corp_name 은
   존재하지 않는다."
   - SKILL.md #1 endpoint 섹션 주의문
   - SKILL.md Response policy
   - docs/features/k-dart.md 에러/제약 섹션
   이전에는 SKILL.md는 "위 공식 요청 파라미터 표에"로 docs/features는
   "list.json 공식 요청 파라미터 표에" 로 prefix가 달라 verbatim
   일치하지 않았음.

2) list.json 표 헤더 문구를 "공식 가이드 표를 그대로 옮긴 것"에서
   "공식 가이드 요청 인자 정리 (필수여부·기본값·허용값은 공식 표
   기준, 식별자는 코드 폰트로 표기)"로 약화. 마크다운 backtick 등
   포매팅 차이가 "1:1 mirror" 약속과 모순되지 않게 정확히 표현.

---------

Co-authored-by: hon2be <hon2be>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>

* WIP korean-slang-writing (#133): scaffold slang_search.py

* WIP korean-slang-writing (#133): add http + lookup scripts

* WIP korean-slang-writing (#133): add seed index of 30 curated trending slang

* WIP korean-slang-writing (#133): add test suite

* korean-slang-writing (#133): fix module-loader sys.modules registration

* korean-slang-writing (#133): add SKILL.md

* korean-slang-writing (#133): add feature doc

* korean-slang-writing (#133): register skill in README and root lint/test pipeline

* Revert out-of-scope HWP README edits to unblock CI

The prior commit 4c7877a on this branch renamed the HWP feature row to
'HWP 문서 조회/변환' and added two new rows ('HWP 문서 편집',
'HWP 레이아웃·IR 디버깅') pointing at docs/features/rhwp-edit.md and
docs/features/rhwp-advanced.md. Those docs do not exist on any branch
in this repo, and the rename violates scripts/skill-docs.test.js
assertions at lines 210, 223, 224, which caused the CI 'validate' job
to fail.

Those changes belong to a separate rhwp-edit/rhwp-advanced feature
effort (tracked elsewhere), not to issue #143 'naver-news-search'.
Revert README.md in both the feature table and the list section so the
only additions in this PR relative to origin/dev are the two
in-scope naver-news-search entries.

Verified by running 'npm run ci' locally (EXIT=0). skill-docs.test.js
now passes 110/110 (previously failed 2/110) and the full
k-skill-proxy suite remains 198/198 including the 14 naver-news tests.

* Update skill-docs tests to cover rhwp-edit, rhwp-advanced, and the k-skill-rhwp package

Pins the HWP table row rename to 'HWP 문서 조회/변환', asserts the new
'HWP 문서 편집' and 'HWP 레이아웃·IR 디버깅' README rows and their linked
feature docs, pins the new SKILL.md routing policy for rhwp-edit and
rhwp-advanced (k-skill-rhwp CLI + @rhwp/core for editing vs upstream
Rust CLI for layout/IR debugging), and asserts the k-skill-rhwp
package.json wiring (bin mapping, @rhwp/core dependency, Node 18+
engines, wasm-init shim + CLI bin files).

Per AGENTS.md rule, no assertion is added on the presence of any
.changeset/*.md file so the changeset release flow can consume the
rhwp-edit-skill.md entry without breaking CI at version-bump time.

Also captures the package-lock.json delta introduced by adding the
k-skill-rhwp workspace (pulls @rhwp/core@0.7.3 and its WASM binary).

Refs #155.

* Polish naver-news: preflight, link canonicalization, /health docs (#143)

Address the three non-blocking items flagged in the round 1/2 reviews. All
were explicitly deferred by the reviewer as "follow-up if the maintainer
wants" — picking them up now so the feature lands with a tighter surface.

1) Preflight 400 for start + display - 1 > 1000
   Naver's official news endpoint only exposes the first 1000 items
   (start 1..1000, display 1..100). Asking for start=1000 & display=100
   would send a request that silently returns no usable items, wasting
   an upstream quota call. Reject the combination before calling upstream
   with a 400 bad_request and a message that tells the caller which item
   the request would have needed and what the cap is. Boundary values
   (start + display - 1 === 1000) are still accepted.

2) Canonical link dedup
   The previous dedup key was link.toLowerCase(), which failed to merge
   the same article when Naver's redirect URLs differed only by query-param
   order, trailing slash, host-name casing, or fragment. Added
   canonicalizeLinkForDedup() which parses the URL, sorts search params by
   key, strips a single trailing pathname slash, drops the fragment, and
   lowercases the result — conservative on purpose so different paths or
   different query values stay as distinct articles. The visible
   items[].link value is still the original URL returned by Naver; only
   the dedup key is canonicalized.

3) Clarify the naverSearchApiConfigured vs naverNewsApiConfigured split
   The two flags currently evaluate the same boolean, but their semantic
   contracts differ: naverSearchApiConfigured reports "are the Naver
   Open API keys configured" (which is advisory for the shopping route
   since shopping has a BFF fallback), while naverNewsApiConfigured
   reports "is the news route operational end-to-end" (no fallback — 503
   when false). Hoist the shared expression into a local, and add a
   `/health 업스트림 플래그 의미` section to packages/k-skill-proxy/README.md
   documenting the split. Also update naver-news-search SKILL.md and
   docs/features/naver-news-search.md to mention the new preflight and
   the canonical-link dedup behavior.

TDD verification: added 4 new node:test cases exercising the boundary,
overflow, and URL-dedup paths; ran the full k-skill-proxy workspace
suite (202/202 pass) plus the root `npm run ci` (exit 0). Manual QA on
a proxy started from this commit reproduces every round-1 case plus the
new preflight: start=1000 & display=100 → 400 bad_request before
upstream; start=1000 & display=1 and start=901 & display=100 → 503 (or
200/401 depending on keys), confirming the boundary passes preflight.

* korean-slang-writing (#133): fix broken seed namuwiki URLs + add encoding invariant test

Reviewer flagged 4/30 seed namuwiki_url values returning HTTP 404 on live
Namu Wiki. These URLs are part of the documented response contract and get
surfaced directly to agents, so broken links are a functional bug, not a
cosmetic one.

Root causes per entry:
- 중꺾마: wrong 꺾 codepoint (U+AFFA 꿺 instead of U+AEBE 꺾).
- 아아: typo in aliased title (아이스 아메리칸노 instead of 아메리카노).
- 어쩔티비: missing 받침 (어쩌티비 instead of 어쩔티비).
- 당모치: encoding correct but no live Namu Wiki article exists; dropped.

Also fixes two separately-broken 중꺾마 example URLs in SKILL.md
(U+AFBE 꾾 instead of U+AEBE 꺾) — these were discovered while auditing
the seed and would have surfaced as 404 to agents following the example
snippets.

Adds two regression tests:
- test_each_seed_url_decodes_to_term_or_alias: decodes every seed URL's
  path segment and asserts it equals the term or one of its aliases.
  Catches Hangul-codepoint typos offline (no network dependency) and
  would have caught all 3 encoding bugs in this PR.
- test_no_seed_entry_points_at_known_missing_namuwiki_page: locks the
  당모치 drop so nobody re-adds an entry pointing at a page that does
  not exist on Namu Wiki.

Fixes the existing LookupNetworkTest assertion that was hard-coding the
broken URL — it now derives the expected URL via build_namuwiki_url()
so the test cannot drift out of sync with the helper again.

Verification:
- PYTHONPATH=.:scripts python3 -m unittest scripts.test_korean_slang_writing -> 40/40 pass
- Live GET with browser headers against all 29 remaining seed URLs -> 29/29 return 200
- npm run ci -> exit 0
- Manual QA: slang_search on 중꺾마, 어쩔티비, 아이스 아메리카노 returns
  correct URLs; slang_lookup live-fetches 중꺾마 and extracts the
  canonical title '중요한 것은 꺾이지 않는 마음'.

* korean-slang-writing (#133): extract summaries via h2 section anchor + og:description fallback

Namu Wiki's current HTML layout uses build-time-obfuscated CSS class
names (e.g. _36R8DWTn, OZVChh+l) and has no <article>/<main>/<section>
tags, so all six MAIN_CONTENT_CLASSES anchors fail to match and
extract_summary() returned empty with a 'Main content region not
detected' warning on every live page.

Replace the single class-based strategy with a three-tier fallback
chain that pins to progressively weaker but more structurally stable
anchors:

  1. First h2 section boundary. Namu Wiki articles consistently open
     with '<h2>1. 개요[편집]</h2>' and mark subsequent sections with
     numbered h2 headings. Extracting text between the first and
     second h2 reliably captures the overview section on every page
     sampled (중꺾마, 갓생, 럭키비키, 어쩔티비).
  2. MAIN_CONTENT_CLASSES / <article> - kept as a legacy fallback
     for older Namu Wiki layouts and for third-party fixtures.
  3. og:description meta tag - final safety net before returning
     empty, gives the agent at least a ~64-char preview when the
     article has unusual structure.

Strip '[편집]' edit-affordance markers and numbered section prefixes
(e.g. '1.2.') from the extracted text so headings don't leak through
as noise.

Live verification (text format):
  slang_lookup.py 중꺾마   -> Title + 286-char summary
  slang_lookup.py 갓생     -> Title + 96-char summary
  slang_lookup.py 럭키비키 -> Title + 59-char summary
  slang_lookup.py 어쩔티비 -> Title + 20-char summary

All previously-empty. Not-found / blocked / upstream-error paths and
exit codes are unchanged.

* korean-slang-writing (#133): harden extractor with numbered-h2 gate + category-nav strip

Implements the three non-blocking observations from PR #161 round-3 review:

1. Numbered-h2 gate (reviewer-flagged fragility):
   Refactored _extract_first_section_between_h2 to extract h2 inner text
   (stripping nested tags) and filter by '^\\s*\\d+(?:\\.\\d+)*\\.\\s+\\S'.
   Sidebar widgets like <h2>관련 문서</h2> or <h2>외부 링크</h2> can no longer
   anchor the extractor - only numbered section headers (1., 1.2., 2.3.4.) do.
   Handles live Namu Wiki structure where the number sits inside an <a> tag
   (<a>1.</a> <span>개요</span>), which the round-3 suggested regex-only gate
   missed. All 29 seed pages continue to produce valid summaries on live
   fetches.

2. Category-nav template strip (reviewer-flagged long-page noise):
   a. CATEGORY_NAV_RE strips the inline '[펼치기 · 접기]' marker plus its
      same-line aftermath (the category list items on the same line).
   b. DETAILS_PELCHIGI_RE strips the entire <details> block whose <summary>
      contains 펼치기. Namu Wiki today wraps category nav in exactly this
      structure, so the strip removes the full noise block (not just the
      marker line).
   꿀잼 summary drops from 3482 chars of category dump to 562 chars
   starting with the real definition '무언가가 매우 재미있다는 의미의 인터넷
   유행어'. Non-category <details> blocks (spoilers, footnotes) are
   preserved.

3. TDD + mutation coverage:
   6 new tests total: 2 numbered-h2 gate tests, 2 inline category-nav tests,
   1 <details>-block strip test, 1 <details>-keep test (negative case).
   All 6 were written first and confirmed RED against the round-2 baseline,
   then made GREEN after the implementation landed. Each fix path was also
   mutation-tested (revert regex, remove .sub line) to confirm the tests
   genuinely catch the target bug class.

Suite grows from 45 to 51 tests. All pass. npm run ci exits 0.

* rhwp-edit (#155): fix replace-all silent no-op and document body-only scope

Upstream @rhwp/core HwpDocument.replaceAll returns {ok:true, count:N} but
does not persist the mutation into exportHwp() serialization, so the output
bytes are byte-identical to the input. This is confirmed against
@rhwp/core@0.7.3 with SHA diffing and round-trip searchText.

Rewrite the Node wrapper replaceAll to compose engine primitives that do
persist: for each body paragraph, read the full text via getTextRange,
compute all non-overlapping match offsets in JS, then apply replaceText
right-to-left so earlier offsets are unaffected by length changes. This
restores the documented '2025 → 2026 일괄 치환' headline workflow.

Guard rails in the new replaceAll:
- Reject replacements containing newline or paragraph-break characters
  (\n, \r, U+2028, U+2029) with a descriptive error. Splitting a paragraph
  via replaceText would invalidate subsequent offsets.
- Non-overlapping semantics against the original text, so
  --query a --replacement aa against 'aaa' yields 'aaaaaa' (3 replacements)
  instead of looping on the freshly inserted 'a' characters.

Tighten the regression tests to assert content, not just length:
- Same-length replacement: output SHA must differ from input, searchText
  must find the replacement and must NOT find the original query.
- Longer-length replacement: paragraph length must grow by the correct
  amount and output SHA must differ.
- Shorter-length replacement: paragraph length must shrink by the correct
  amount and output SHA must differ.
- Empty replacement: deletes every match and output no longer contains
  the query.
- Replacement contains query (a→aa on aaa): expects count 3 and length 6.
- Zero matches: count 0, output still written.
- Case-sensitive flag skips mismatched case.
- Newline replacement is rejected synchronously.

Document the body-only scope of search and replace-all in the SKILL.md
routing policy, failure-modes, CLI USAGE text, feature doc, and package
README so users know to use set-cell-text for cell content. This matches
the upstream searchText contract, which does not descend into table cells,
headers, footers, or footnotes.

Add a matching regression assertion to scripts/skill-docs.test.js so the
body-only scope note cannot be silently removed from SKILL.md or the
feature doc.

Closes review round 1 for PR #162.

* rhwp-edit (#155): guard replace-all case-insensitive path against UTF-16 length-drift

Round 2 review flagged a latent Unicode safety bug: when replaceAll's
caseSensitive=false branch encounters characters whose toLowerCase()
changes UTF-16 length (e.g. Turkish İ U+0130 → i + U+0307 combining dot
above), offsets taken in the lowercased haystack drift by the expansion
delta for every subsequent match and silently corrupt the document.
Reviewer repro: 'ABCİABCİXYZ' + case-insensitive İ→Z reported
{ok:true,count:2} but rendered 'ABCZABCİZYZ' instead of 'ABCZABCZXYZ'
(the X at index 8 was corrupted while the second İ survived).

Surface a descriptive error rather than silently drift:
- findAllMatchOffsets: in the case-insensitive branch, verify that the
  paragraph text and the query each preserve UTF-16 length under
  toLowerCase; otherwise throw with an actionable message pointing the
  user to --case-sensitive or input normalization.
- This is strictly a safety guard: the 2025→2026 headline workflow,
  ASCII, Hangul, and every existing test are unaffected.

Tests (TDD red → green, net +4 in packages/k-skill-rhwp):
- 'replaceAll refuses case-insensitive matching when source text
  contains case-folding length-changing chars (e.g. Turkish İ U+0130)'
  reproduces the exact reviewer input and asserts rejection + no output
  file
- 'replaceAll refuses case-insensitive matching when the query itself
  contains case-folding length-changing chars' covers the query-side path
- 'replaceAll with --case-sensitive succeeds on inputs containing İ'
  confirms the guard only fires in the case-insensitive path and that
  case-sensitive produces ABCZABCZXYZ with no X corruption
- 'replaceAll case-insensitive still works for normal ASCII/Hangul'
  regression-guards against the fix over-rejecting the common case

Doc disclosure in all 4 surfaces called out by the reviewer:
- rhwp-edit/SKILL.md: new failure-mode bullet naming U+0130 specifically
- docs/features/rhwp-edit.md: Unicode 대소문자 무시 주의 paragraph
  under scenario 3 (replace-all)
- packages/k-skill-rhwp/README.md: extended Scope section
- packages/k-skill-rhwp/src/cli.js: USAGE 'Scope note' appended
- scripts/skill-docs.test.js: 2 new assertions locking the SKILL.md and
  feature-doc disclosure so they can't be silently removed
- .changeset: note the guard in the pending v0.1.0 release notes

Manual QA (end-to-end via the published CLI):
  $ k-skill-rhwp replace-all … --query İ --replacement Z
  → exit 1 + 'case-insensitive matching is unsafe because case folding
    changes the UTF-16 length …'
  → no output file written
  $ k-skill-rhwp replace-all … --query İ --replacement Z --case-sensitive
  → {ok:true,count:2}, render shows 'ABCZABCZXYZ', search İ ⇒ found:false
  $ replace-all '2025'→'2026' on '2025 2025 2025' ⇒ {ok:true,count:3}
  $ replace-all 'hello'→'hi' (case-insens.) on 'hello WORLD 안녕 HELLO'
    ⇒ {ok:true,count:2}

Verification:
- npm test --workspace k-skill-rhwp: 35 pass / 0 fail (+4 vs Round 2)
- node --test scripts/skill-docs.test.js: 114 pass / 0 fail
- npm run ci: exit 0 (lint + typecheck + all workspace tests +
  pack:dry-run + validate-skills.sh all green)

Refs PR #162 Round 2 review 'Non-blocking residual risk — Unicode
case-insensitive offset drift'.

* Document preflight 400 and full canonical dedup contract in naver-news feature doc

Round-3 review flagged two non-blocking doc-completeness nits in docs/features/naver-news-search.md:

- 실패 모드의 `400 bad_request` 항목이 preflight 케이스(`start + display - 1 > 1000`)를 누락하고 있었음. SKILL.md line 94 와 본문 line 128 의 '운영 팁' 과 대칭이 되도록 업데이트.
- 운영 팁의 canonical dedup 설명이 쿼리 파라미터 순서와 trailing slash 만 언급해서, 실제 구현(`canonicalizeLinkForDedup`)이 같이 정규화하는 host 대소문자와 URL fragment 를 빠뜨리고 있었음. test/naver-news.test.js line 273 이 네 가지 모두 검증하고 있으므로 공개 문서를 구현과 테스트에 맞춰 정정.

* feat: add catchtable-sniper skill (#146)

* feat: add catchtable-sniper skill

* Make the Catchtable skill loadable and discoverable

The submitted skill landed under skills/ without YAML frontmatter, which broke the repo's auto-discovery contract and Codex skill loading. Move it to the root-level skill layout, add the required metadata block, and document the feature in the main README plus a dedicated guide so the PR ships in a usable state.

Constraint: This repository auto-discovers skills from root-level directories only
Constraint: Skill manifests must start with YAML frontmatter for Codex to load them
Rejected: Keep the nested skills/catchtable-sniper layout | validate-skills and the repo's documented convention reject it
Rejected: Add only README links without a feature guide | would create a broken documentation target
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Future skill PRs should follow docs/adding-a-skill.md and place each skill in its own root directory
Tested: node --test scripts/skill-docs.test.js
Tested: ./scripts/validate-skills.sh
Tested: git diff --check
Not-tested: End-to-end Catchtable reservation completion on a logged-in account

---------

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

---------

Co-authored-by: minsing-jin <ironman0722@naver.com>
Co-authored-by: hon2be <saysun34@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: choihyun-1110 <74152226+choihyun-1110@users.noreply.github.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-24 10:41:21 +09:00 committed by GitHub
commit 4fc01391ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 8847 additions and 39 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/lh-notice/search` and `/v1/lh-notice/detail` routes plus matching `lh-notice-search` skill. Proxies the official LH 청약 (Korea Land & Housing Corporation lease/subscription) notice API on `apis.data.go.kr/B552555/lhLeaseNoticeInfo1/*`, reuses the existing `DATA_GO_KR_API_KEY`, and keeps the user-facing credential surface empty ("불필요"). Handles the LH-specific `[CMN, dsList]` JSON envelope plus the standard data.go.kr XML auth-error envelope, does not cache upstream failures, and exposes `lhNoticeConfigured` on `/health`. Closes #145.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/naver-news/search` route plus matching `naver-news-search` skill. Proxies the official Naver Search Open API news endpoint (`openapi.naver.com/v1/search/news.json`), reuses the existing `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` credentials, and keeps the user-facing credential surface empty ("불필요"). Strips `<b>` highlight tags and decodes HTML entities in titles/descriptions, parses RFC822 `pubDate` into ISO-8601, deduplicates results by canonicalized `link` (query-param order, trailing slash, host casing and fragments are ignored; different paths or query values are preserved), caches successes for 5 minutes (failures are not cached), and exposes `naverNewsApiConfigured` on `/health`. The route rejects `start + display - 1 > 1000` with a `400 bad_request` preflight before calling upstream, so requests outside Naver's 1000-item search window fail fast with a clear message instead of returning empty results. Closes #143.

View file

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

View file

@ -0,0 +1,5 @@
---
"k-skill-rhwp": minor
---
Introduce the initial `k-skill-rhwp` Node CLI + library that wraps `@rhwp/core` WASM editing bindings as subcommands (`info`, `list-paragraphs`, `search`, `insert-text`, `delete-text`, `replace-all`, `create-table`, `set-cell-text`, `create-blank`, `render`). This is the editing engine backing the new `rhwp-edit` skill and is the counterpart to the existing `hwp` (kordoc, read/convert) and the new `rhwp-advanced` (upstream rhwp Rust CLI) skills. Case-insensitive `replace-all` rejects inputs whose case folding changes UTF-16 length (e.g. Turkish `İ` U+0130) with exit code 1 instead of silently drifting offsets; rerun with `--case-sensitive` for those documents. Closes #155.

View file

@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| LH 청약 공고문 조회 | 한국토지주택공사(LH) 임대/분양/주거복지(신혼희망타운)/토지/상가 공고를 지역·상태·공고유형·키워드로 조회하고 마감 여부를 KST 기준으로 표시 | 불필요 | [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md) |
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
@ -39,6 +40,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
| 식품 안전 체크 | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
@ -50,8 +52,11 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| LCK 경기 분석 | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 하이패스 영수증 발급 | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
| 캐치테이블 예약 스나이핑 | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 로또 당첨 확인 | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 조회/변환 | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 레이아웃·IR 디버깅 | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
@ -66,7 +71,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국어 맞춤법 검사 | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
| 네이버 블로그 리서치 | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
| 네이버 쇼핑 가격비교 | 네이버 검색 Open API 우선, 공개 BFF JSON fallback으로 상품 후보·현재 노출가·판매처 링크 비교 | 불필요 | [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md) |
| 네이버 뉴스 검색 | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
| 한국어 글자 수 세기 | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
| 한국어 유행어 글쓰기 | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
>
@ -110,6 +117,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [LH 청약 공고문 조회 가이드](docs/features/lh-notice-search.md)
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
@ -128,8 +136,11 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
- [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)
- [HWP 문서 조회/변환](docs/features/hwp.md)
- [HWP 문서 편집](docs/features/rhwp-edit.md)
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
@ -144,7 +155,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)
- [네이버 쇼핑 가격비교 가이드](docs/features/naver-shopping-search.md)
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
- [릴리스/배포 가이드](docs/releasing.md)
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.

284
catchtable-sniper/SKILL.md Normal file
View file

@ -0,0 +1,284 @@
---
name: catchtable-sniper
description: Monitor Catchtable for open reservation slots and attempt booking using a logged-in Chrome session.
license: MIT
metadata:
category: lifestyle
subcategory: food
locale: ko-KR
phase: v1
requires:
- Chrome MCP
- Logged-in Catchtable Chrome session
---
# catchtable-sniper
## 📋 기본 정보
- **스킬명**: catchtable-sniper
- **라이선스**: MIT
- **단계**: v1
- **카테고리**: lifestyle / food
- **로케일**: ko-KR
- **요구사항**: Chrome MCP, 캐치테이블 로그인된 Chrome 세션
---
## 🎯 주요 기능
캐치테이블에서 원하는 식당의 빈자리(취소 슬롯)를 30초 간격으로 감시하다가 발견하는 즉시 자동 예약합니다.
멀티 타겟 동시 감시, 예약 오픈런 모드, 인원 유연 매칭, Dry-run 알림 전용 모드를 지원합니다.
---
## ✅ 적합한 사용 사례
- `"온지음 5월 토요일 저녁 2인 빈자리 나오면 예약해줘"`
- `"온지음, 밍글스, 라연 중 5월 주말 2인 아무데나 먼저 뜨는 거 잡아줘"` ← 멀티 타겟
- `"라연 5월 예약 오픈이 4월 30일 오전 10시야, 그때 맞춰서 잡아줘"` ← 오픈런 모드
- `"스시야마 이번달 안에 2인 — 못 잡으면 4인 있으면 알려줘"` ← 인원 유연
- `"밍글스 빈자리 뜨면 예약은 내가 할게 알림만 줘"` ← Dry-run 모드
- `"https://app.catchtable.co.kr/ct/shop/mingles 토요일 4명 자동예약"`
---
## ❌ 부적합한 사용 사례
- 로그인 자동화 (카카오/네이버 로그인은 직접 해야 함)
- 선결제 식당의 결제 정보 자동 입력 (결제 단계는 사람이 직접)
- 캐치테이블 외 플랫폼 예약 (네이버 예약, 식신 등)
- 30초 미만 폴링 간격 (서버 부하 방지)
---
## 🔧 기술 요구사항
- **Chrome MCP** 연결 필수
- 캐치테이블(`app.catchtable.co.kr`)에 **로그인된 Chrome 세션** 필요
- 별도 API 키, 패키지 설치 불필요
---
## 🔐 인증 처리
이 스킬은 이미 Chrome에 로그인된 세션을 그대로 사용합니다.
로그인 정보를 스킬에 전달하지 않습니다.
로그인 안 된 경우:
```
"캐치테이블에 로그인되어 있지 않습니다.
Chrome에서 캐치테이블에 카카오/네이버 로그인 후 다시 실행해주세요."
```
→ 스킬 중단. 로그인 자동화 없음.
---
## 🗂️ 입력 파싱
사용자 입력에서 다음을 추출한다:
| 항목 | 예시 | 필수 여부 |
|------|------|----------|
| 식당명 또는 URL | `"온지음"` / `app.catchtable.co.kr/ct/shop/onjium` | 필수 (복수 가능) |
| 날짜 | `"5월 3일"`, `"이번 주 토요일"`, `"5월 주말 전체"` | 필수 |
| 인원 | `"2명"`, `"4인"` | 필수 |
| 시간대 | `"저녁"`, `"19시 이후"` | 선택 (없으면 전체) |
| 모드 | `"알림만"`, `"dry-run"` | 선택 (없으면 자동예약) |
| 인원 유연 | `"2인 없으면 4인도 괜찮아"` | 선택 |
| 오픈 시간 | `"4월 30일 오전 10시 오픈"` | 선택 (오픈런 모드) |
| 폴링 간격 | `"30초마다"` | 선택 (기본: 30초) |
**멀티 타겟 감지**: 식당명이 쉼표/슬래시로 구분되거나 "중 아무데나", "먼저 뜨는 거" 표현이 있으면 멀티 타겟 모드로 전환.
---
## 📊 실행 플로우
### STEP 1 — 브라우저 준비 및 로그인 확인
Chrome MCP로 캐치테이블 접속:
```
navigate: https://app.catchtable.co.kr
```
MY 탭에서 로그인 상태 확인. 미로그인 시 중단.
---
### STEP 2 — 모드 분기
```
입력 파싱 완료
├─ 오픈 시간 명시됨 → STEP 2-A (오픈런 모드)
└─ 오픈 시간 없음 → STEP 2-B (취소 스나이핑 모드)
```
#### STEP 2-A: 오픈런 모드
예약 오픈 시간까지 대기:
```
[10:00:00 오픈 예정] 현재 09:58:42 — 77초 후 오픈
[10:00:00] ✅ 오픈 시각 도달 — 즉시 예약 시도
```
오픈 시각 정각에 날짜 선택 → 슬롯 클릭 → 예약 폼 진입.
슬롯이 이미 마감이면 → 취소 스나이핑 모드(STEP 2-B)로 자동 전환.
#### STEP 2-B: 취소 스나이핑 모드 (폴링 루프)
```
while 빈자리 없음:
{폴링 간격}초 대기
페이지 새로고침 또는 날짜 재클릭
슬롯 파싱
빈자리 발견 → STEP 3
```
---
### STEP 3 — 멀티 타겟 처리
**단일 타겟**: 해당 식당 슬롯 확인.
**멀티 타겟**: 지정된 식당들을 순차 순회하며 슬롯 확인.
```
[14:23:15] 온지음 5/3 확인 중... 없음
[14:23:17] 밍글스 5/3 확인 중... 없음
[14:23:19] 라연 5/3 확인 중... 없음 (30초 후 재시도)
[14:23:49] ✅ 밍글스 5/3 19:30 빈자리 발견! — 예약 시작
```
한 곳에서 슬롯 발견 시 나머지 감시 즉시 중단 → 발견된 식당 예약 진행.
---
### STEP 4 — 인원 유연 매칭
지정 인원(예: 2인) 슬롯이 없을 경우:
```
if 인원_유연 == True:
대안_인원(예: 4인) 슬롯 확인
발견 시:
"2인 슬롯은 없지만 4인 슬롯(19:00)이 있습니다.
4인으로 예약할까요? (예/아니오)"
→ 사용자 확인 후 진행
```
---
### STEP 5 — 예약 진행 (모드 분기)
**Dry-run 모드** (`"알림만"` / `"dry-run"` 입력 시):
```
✅ 빈자리 발견! 예약은 진행하지 않습니다.
식당: 밍글스
날짜: 5월 3일(토)
시간: 19:30
인원: 2명
→ 지금 바로 예약하시겠습니까? (예/아니오)
```
→ 예약 여부는 사람이 결정.
**자동예약 모드** (기본):
빈 슬롯 버튼 즉시 클릭 → 예약 폼 진입.
폼 자동 입력:
- 인원수: 지정한 인원 선택
- 방문 목적: "식사" (기본값)
- 주의사항 동의: 전체 동의 체크
- 예약자 정보: 앱 저장 정보 자동 사용
**선결제 식당인 경우**:
```
"빈자리를 발견했습니다! 결제가 필요합니다.
결제 금액: {금액}원
지금 결제를 진행할까요? (예/아니오)"
```
→ 결제 정보 자동 입력 없음. 사용자 확인 후 결제 진행.
**무료 예약**: "예약하기" 최종 확인 버튼 클릭.
---
### STEP 6 — 완료 확인
```
🎉 예약 완료!
식당: {식당명}
날짜: {날짜}
시간: {시간}
인원: {인원}명
모드: {자동예약 / Dry-run}
예약번호: {예약번호}
캐치테이블 앱 > MY > 예약내역에서 확인 가능합니다.
```
---
## 💡 중간 상태 출력 형식
```
[14:23:15] 밍글스 5/3 저녁 슬롯 확인 중... 빈자리 없음 (30초 후 재시도)
[14:23:45] 온지음 5/3 저녁 슬롯 확인 중... 빈자리 없음
[14:24:15] ✅ 밍글스 5/3 19:30 (2인) 빈자리 발견! — 예약 시작
```
---
## ⚙️ 설정값
| 항목 | 기본값 | 범위 |
|------|--------|------|
| 폴링 간격 | 30초 | 30초 이상 |
| 최대 감시 시간 | 2시간 | — |
| 멀티 타겟 최대 수 | 5개 | — |
2시간 초과 시:
```
"2시간 동안 빈자리가 없었습니다. 계속 시도할까요? (예/아니오)"
```
---
## 🚨 에러 핸들링
| 상황 | 대응 |
|------|------|
| 식당 페이지 404 | "식당을 찾을 수 없습니다. 이름을 다시 확인해주세요." |
| 예약 오픈 전 | 오픈 일정 안내 후 오픈런 모드로 전환 제안 |
| 슬롯 클릭 후 이미 마감 | 즉시 재폴링 재개 |
| 네트워크 오류 | 10초 후 재시도, 3회 연속 실패 시 사용자 알림 |
| 멀티 타겟 중 일부 404 | 해당 식당 제외, 나머지 계속 감시 |
| 2시간 초과 | "계속 시도할까요?" 확인 후 연장 또는 종료 |
---
## ✨ 완료 기준
다음 중 하나:
- 예약 완료 화면 확인 + 예약번호 수집
- Dry-run 모드에서 빈자리 발견 및 사용자 알림 완료
- 사용자가 명시적으로 중단 요청
---
## 사용 예시
```
"온지음 5월 10일 저녁 2인 빈자리 나오면 예약해줘"
"온지음, 밍글스, 라연 5월 토요일 저녁 2인 중 아무데나 먼저 뜨는 거 잡아줘"
"라연 5월 예약이 4월 30일 오전 10시 오픈이야, 그때 맞춰 2인 잡아줘"
"스시야마 이번달 2인 — 없으면 4인도 괜찮아, dry-run으로"
"https://app.catchtable.co.kr/ct/shop/mingles 토요일 4명 자동예약"
```
---
## ⚠️ 주의사항
- Chrome에 캐치테이블 로그인 세션이 있어야 동작합니다.
- 선결제 식당의 결제 정보는 직접 입력해야 합니다.
- 폴링 간격은 최소 30초를 유지합니다 (서버 부하 방지).
- 캐치테이블 이용약관을 준수하는 범위에서 사용하세요.

View file

@ -0,0 +1,90 @@
# 캐치테이블 예약 스나이핑 가이드
## 이 기능으로 할 수 있는 일
- 로그인된 Chrome 세션을 재사용해 캐치테이블 예약 페이지 진입
- 원하는 식당의 취소 슬롯/빈자리 폴링
- 여러 식당을 순차 감시하다가 먼저 열린 슬롯에 예약 시도
- 예약 오픈 시간에 맞춘 오픈런 시도
- dry-run 모드로 빈자리 발견까지만 알리고 최종 예약은 사용자에게 넘기기
## 먼저 알아둘 점
- 이 기능은 **Chrome MCP + 로그인된 캐치테이블 세션**이 있어야만 동작한다.
- 카카오/네이버 로그인 자동화는 하지 않는다.
- 결제 정보 자동 입력은 하지 않는다.
- 선결제 매장은 결제 단계에서 반드시 사용자가 직접 확인해야 한다.
- 서버 부하를 줄이기 위해 폴링 간격은 **30초 이상**으로 유지한다.
## 입력 형태
다음 정보를 자연어에서 추출해 사용한다.
- 식당명 또는 캐치테이블 URL
- 날짜 또는 날짜 범위
- 인원 수
- 시간대(선택)
- dry-run 여부
- 인원 유연 매칭 여부
- 예약 오픈 시각(오픈런 모드일 때)
예시:
- `온지음 5월 토요일 저녁 2인 빈자리 나오면 예약해줘`
- `온지음, 밍글스, 라연 중 5월 주말 2인 아무데나 먼저 뜨는 거 잡아줘`
- `라연 5월 예약 오픈이 4월 30일 오전 10시야, 그때 맞춰 2인 잡아줘`
- `밍글스 빈자리 뜨면 예약은 내가 할게, dry-run으로`
- `https://app.catchtable.co.kr/ct/shop/mingles 토요일 4명 자동예약`
## 동작 흐름
1. 캐치테이블 홈 또는 식당 페이지에 접속한다.
2. 로그인 상태를 확인한다.
3. 오픈런 모드면 지정 시각까지 대기 후 즉시 예약을 시도한다.
4. 일반 스나이핑 모드면 30초 간격으로 새로고침/재조회하며 슬롯을 감시한다.
5. 슬롯이 열리면 날짜/인원/시간을 선택하고 예약 흐름으로 진입한다.
6. 무료 예약이면 최종 예약 버튼까지 진행하고, 선결제 매장이면 결제 직전 단계에서 사용자 확인을 요구한다.
## 멀티 타겟 / 인원 유연 모드
### 멀티 타겟
- 여러 식당을 순차적으로 감시한다.
- 한 곳에서 예약 가능한 슬롯을 발견하면 나머지 감시는 즉시 중단한다.
### 인원 유연 매칭
- 예를 들어 2인 자리가 없을 때 4인 자리를 대안으로 확인할 수 있다.
- 대안 인원 슬롯을 발견하면 사용자에게 확인을 받고 다음 단계로 진행한다.
## dry-run 모드
`알림만`, `dry-run` 같은 표현이 있으면 예약 완료 대신 다음까지만 수행한다.
- 빈자리 발견
- 식당/날짜/시간/인원 요약
- 사용자가 직접 예약할 수 있도록 알림
## 제한사항
- 로그인 자동화 없음
- 카드/간편결제 정보 자동 입력 없음
- 캐치테이블 외 예약 플랫폼 미지원
- UI 변경 시 selector/흐름이 깨질 수 있음
## 검증 메모
2026-04-22 기준 로컬 검증에서 다음을 확인했다.
- 캐치테이블 식당 페이지 진입
- 예약 가능한 식당에서 날짜/인원/시간 선택
- 방문 확인 단계 진입
- 결제 방식 선택 단계 진입
다만 최종 예약 완료는 **로그인된 캐치테이블 세션이 없는 Chrome 프로필**에서는 검증할 수 없었다. 이 기능의 최종 성공 여부는 로그인된 사용자 세션과 실시간 좌석 상황에 직접 의존한다.
## 원칙
- 사용자의 로그인 자격 증명을 새 env var나 repo 문서에 추가하지 않는다.
- 사용자가 이미 로그인해 둔 브라우저 세션만 재사용한다.
- 결제나 취소 수수료가 얽힌 단계에서는 사용자 확인을 우선한다.

75
docs/features/k-dart.md Normal file
View file

@ -0,0 +1,75 @@
# 금감원 DART 전자공시 조회 가이드
## 이 기능으로 할 수 있는 일
- 공시검색 (최근 공시 목록, 기업별 공시 이력)
- 기업개황 (대표자, 업종, 주소, 결산월 등)
- 재무제표 (매출액, 영업이익, 당기순이익, 자산/부채/자본 등)
- 배당에 관한 사항
- 증자(감자) 현황
- 자기주식 취득 및 처분 현황
- 회계감사인의 명칭 및 감사의견
- 직원 현황 (부문별/성별 정규직·계약직 인원)
- 주요사항보고서: 유무상증자 결정, 소송, 해외 상장/상장폐지, 전환사채, 교환사채, 회사분할합병
## 먼저 필요한 것
`API_K_DART` 환경변수에 DART OpenAPI 인증키를 설정해야 한다.
키 발급: <https://opendart.fss.or.kr/uss/umt/EgovMberInsertView.do>
## 추천 조회 순서
1. `corpCode.xml` ZIP을 다운로드해 회사명 또는 종목코드로 `corp_code`(8자리 고유번호)를 찾는다.
2. `corp_code`로 기업개황, 재무제표, 감사의견 등 원하는 API를 호출한다.
3. 주요사항보고서는 날짜 범위(`bgn_de`, `end_de`)가 필요하다.
## 검색 예시
공시검색:
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/list.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bgn_de=20260101' \
--data-urlencode 'end_de=20260419' \
--data-urlencode 'page_count=5'
```
재무제표 (연결, 사업보고서):
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/fnlttSinglAcntAll.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bsns_year=2024' \
--data-urlencode 'reprt_code=11011' \
--data-urlencode 'fs_div=CFS'
```
## 응답 해석 팁
- `status: "000"`이 정상. 그 외는 에러 (010=미등록 키, 011=사용 불가 키, 012=접근 불가 IP, 013=조회된 데이터 없음, 020=요청 제한 초과, 100=필드 오류).
- 재무제표의 `thstrm_amount`=당기, `frmtrm_amount`=전기, `bfefrmtrm_amount`=전전기.
- `reprt_code`: 11011(사업보고서), 11012(반기), 11013(1분기), 11014(3분기).
- `fs_div`: CFS(연결), OFS(개별).
## 답변 템플릿 권장
- 회사명 / 시장 / 종목코드
- 회계연도 / 보고서 종류
- 핵심 재무 수치 (매출, 영업이익, 순이익, 자산/부채/자본)
- 마지막 한 줄: `금감원 DART 공시 데이터 기준이며 투자 조언은 아닙니다.`
## 에러/제약
- `API_K_DART` 미설정 시 API 호출 불가 → 키 발급 안내
- DART API 요청 한도: 공식 가이드(020 메시지) 기준 "일반적으로 20,000건 이상의 요청"에 대해 020 (요청 제한 초과)이 발생하며, 키별로 별도 한도가 설정된 경우 다른 임계치에서도 동일 코드가 반환될 수 있음. 분당 throttle 등 세부 수치는 공개 가이드에 명시되지 않음. 본인 키의 정확한 사용 현황은 로그인 후 OpenDART 사이트의 [오픈API 이용현황](https://opendart.fss.or.kr/mng/apiUsageStatusView.do) 페이지에서 확인 가능.
- 상장폐지·오래된 비상장 법인은 데이터가 없을 수 있음
- DART OpenAPI `list.json` 의 공식 요청 파라미터 표에 `corp_name` 은 존재하지 않는다. 회사명 기준으로 좁히려면 `corpCode.xml` ZIP을 받아 `corp_code`(8자리 고유번호)를 먼저 확보한 뒤 `corp_code` 로 호출한다. `corp_code` 자체는 선택사항(공식 가이드: N)이지만, 공식 spec 기준 미지정 시 `bgn_de`~`end_de` 검색 기간이 3개월 이내로 제한된다.
## 참고 링크
- 공식 DART OpenAPI: <https://opendart.fss.or.kr/intro/main.do>
- API 목록: <https://opendart.fss.or.kr/intro/infoApiList.do>

View file

@ -0,0 +1,103 @@
# 한국어 유행어 글쓰기 가이드
## 이 기능으로 할 수 있는 일
- SNS·홍보문·댓글·자기소개를 "유행어 섞인 요즘 말투" 로 작성하거나 리라이팅
- 무드·문맥·안전성(safety)·강도(intensity) 기준으로 한국 유행어 후보를 조회
- 개별 유행어의 뜻을 나무위키 best-effort 요약으로 확인
- 외부 JSON 인덱스로 유행어 풀을 교체·확장 (확장성)
## 왜 별도 스킬이 필요한가
- 한국 유행어는 생성·변형·소멸 속도가 빠르다. LLM 단독 지식에 의존하면 knowledge cutoff 이후 표현은 놓치거나 잘못 쓰기 쉽다.
- 유행어를 아무 때나 자동으로 끼워 넣는 것은 위험하다. `korean-slang-writing`**사용자가 명시적으로 원할 때만** 활성화되고, 근거 링크와 안전성 메타를 함께 돌려준다.
- 다른 한국어 스킬(자기소개서 교정, SNS 카피 작성 등)이 공통으로 참조할 수 있는 유행어 사전 레이어를 제공한다.
## 먼저 필요한 것
- `python3` 3.10+
- 인터넷 연결 (나무위키 lookup 에만 사용, 시드 검색은 오프라인)
- 추가 API 키 없음
## 입력값
- 시드 검색: `--query`, `--mood`, `--context`, `--safety`, `--intensity`, `--limit`, `--include-deprecated`, `--index-path`
- 원문 lookup: 유행어 단어 또는 나무위키 URL
- 출력: JSON(기본) 또는 텍스트
## 기본 계약
- 스킬은 **데이터와 조회 도구** 만 제공한다. 실제 글 작성은 호출하는 에이전트(LLM)가 한다.
- 시드 인덱스 (`data/seed-slang.json`) 는 나무위키 `분류:유행어` 를 참고한 수작업 큐레이션 결과이며, 각 항목은 `term`, `aliases`, `meaning_short`, `usage_context`, `mood_tags`, `intensity`, `safety`, `example_usage`, `namuwiki_url`, `era`, `still_usable` 을 가진다.
- `safety``safe` / `spicy` / `risky` 중 하나다. v1 시드에는 `risky` 항목을 포함하지 않는다.
- 검색 결과의 `match_reason` 우선순위는 `exact > alias > substring > no-query` 다.
- 나무위키 fetch 는 **best-effort** 다. Cloudflare 차단(403/429) 이나 404 시 `fetched: false``block_reason` 으로 보고하고, 에이전트는 추측하지 않는다.
## 기본 흐름
1. 사용자 의도(목적, 톤, 문맥, 강도)를 정리한다.
2. `slang_search.py` 로 시드 인덱스에서 후보를 고른다 (무드/문맥/safety 필터 조합).
3. 필요한 경우 `slang_lookup.py` 로 특정 유행어의 나무위키 원문 요약을 확인한다.
4. 에이전트가 후보 중 1~3개를 골라 자연스럽게 녹여 문장을 완성한다.
5. 결과물과 함께 "사용한 표현 · 의미 · 근거 링크" 를 사용자에게 보여준다.
## CLI 사용 예시
```bash
# 1) 특정 유행어 즉시 조회 (시드 정확 매칭)
python3 scripts/slang_search.py --query "갓생" --format text
# 2) 긍정·유머 무드, SNS/마케팅 문맥에서 안전한 후보 5개
python3 scripts/slang_search.py \
--mood "긍정,유머" \
--context "SNS,마케팅" \
--safety safe \
--limit 5
# 3) 구형 유행어까지 포함해 넓게 조회
python3 scripts/slang_search.py --query "인싸" --include-deprecated
# 4) 나무위키 원문 요약 시도 (실패하면 warning 반환)
python3 scripts/slang_lookup.py "중꺾마" --timeout 10 --format json
# 5) 외부 인덱스 파일로 대체 (PR 없이 커스텀 풀 사용)
python3 scripts/slang_search.py \
--query "럭키비키" \
--index-path ./my-custom-slang.json
```
## 응답 스타일 권장
```text
[추천 문안]
...
[사용한 유행어]
- 갓생: 부지런하고 생산적인 삶 — https://namu.wiki/w/...
- 오운완: 오늘 운동 완료 — https://namu.wiki/w/...
[더 과한 버전]
...
[더 무난한 버전]
...
```
## 제한사항
- 시드 인덱스는 약 30개 항목의 큐레이션 모음이며 전체 나무위키 `분류:유행어` 를 망라하지 않는다.
- 나무위키는 Cloudflare 를 사용하므로 요청량이 많거나 IP 가 의심받으면 403/429 가 나올 수 있다. 이 스킬은 실패를 조용히 덮지 않고 `block_reason` 으로 보고한다.
- `still_usable` 플래그는 수작업으로 관리하며, 자동 만료 로직은 v1 범위가 아니다.
- 스킬은 LLM 호출을 하지 않는다. "유행어 추천 → 문장 작성" 판단은 호출 에이전트가 한다.
## 안전 가이드
- 민감한 상황 (사과문, 공문, 보도자료, 법률 문서) 에서는 사용을 자제한다.
- `spicy` 로 표시된 표현은 비격식 · 친근한 컨텍스트에서만 사용하고, 이유를 한 줄 덧붙인다.
- 유행어의 의미를 시드 또는 나무위키에서 확인하지 못했다면 그 유행어는 쓰지 않는다.
- 사용자가 별도 요청하지 않는 한, 유행어는 **적게** 쓰는 것이 기본값이다.
## 확장 아이디어
- `data/` 에 커뮤니티·브랜드 전용 유행어 인덱스를 별도 파일로 추가 (의도적 사용)
- 자동 만료 점수 (`last_reviewed` + 현재 날짜 기반) 로 구형 항목 자동 demote
- 다중 소스 병합 (나무위키 외 공개 사전/뉴스 기사 요약)

View file

@ -0,0 +1,137 @@
# LH 청약 공고문 조회 가이드
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 임대주택·분양주택·주거복지(신혼희망타운)·토지·상가 공고를 공공데이터포털 공식 LH 공고 Open API(`http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/...`)로 조회한다. 프록시 서버가 `serviceKey` 주입, 캐시, 율속을 맡는다.
## 이 기능으로 할 수 있는 일
- 공고중·접수중·접수마감 등 상태별 LH 공고 목록 조회
- 지역(시/도), 공고 유형(영구임대·행복주택·전세임대·국민임대·매입임대·분양주택·신혼희망타운 등) 필터링
- 공고명 키워드 검색 (예: "행복주택", "든든주택", "청년", "신혼희망")
- 공고 게시일·접수 마감일 구간 필터
- 공고 상세(주택형별 공급 정보, 공식 링크) 확인
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/lh-notice/...` 이다. 사용자는 **공공데이터포털 ServiceKey 를 준비할 필요가 없다**. upstream key(`DATA_GO_KR_API_KEY`)는 프록시 서버에서만 주입한다.
마감 여부는 **KST(Asia/Seoul)** 기준으로 판정한다. host local time 을 쓰지 않는다.
본 스킬은 LH 공고 전용이다. SH(서울), GH(경기), iH(인천) 등 지방 주택공사 공고는 포함되지 않는다. 사용자가 그런 공고를 찾으면 본 스킬 범위가 아님을 분명히 말한다.
## 먼저 필요한 것
- 인터넷 연결
- `curl` 또는 HTTP 호출이 가능한 도구
- (프록시 운영자 전용) `DATA_GO_KR_API_KEY` 환경변수
## 지원 엔드포인트
| Route | 설명 |
| --- | --- |
| `GET /v1/lh-notice/search` | 공고 목록 조회. 필터 전부 선택사항. |
| `GET /v1/lh-notice/detail` | 특정 공고 상세 + 주택형별 공급 정보. `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 모두 필수. |
### `/v1/lh-notice/search` 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `panSs` / `status` | string | (없음) | `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` |
| `uppAisTpCd` / `category` | string | (없음) | `01`=토지, `05`=분양주택, `06`=임대주택, `13`=주거복지, `22`=상가 |
| `aisTpCd` | digits | (없음) | 세부 분류 코드. 예: `09`=영구임대, `10`=행복주택, `17`=전세임대 |
| `cnpCdNm` / `region` | string | (없음) | 지역명(시/도). 예: `서울특별시`, `부산광역시`, `전국` |
| `panNm` / `q` / `keyword` | string | (없음) | 공고명 부분 검색 |
| `panNtStDt` / `startDate` | date | (없음) | 공고 게시일 시작. `YYYY-MM-DD` / `YYYYMMDD` / `YYYY.MM.DD` |
| `clsgDt` / `endDate` | date | (없음) | 접수 마감일 종료 |
| `page` | int | 1 | 페이지 (최대 1000) |
| `pageSize` / `PG_SZ` / `numOfRows` / `limit` | int | 50 | 페이지당 건수 (최대 1000) |
### `/v1/lh-notice/detail` 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
| --- | --- | --- | --- |
| `panId` | digits | ✅ | 공고 ID. 목록 응답의 `pan_id` 와 동일 |
| `ccrCnntSysDsCd` | digits | ✅ | 연계시스템 구분 코드. 목록 응답의 `ccr_cnnt_sys_ds_cd` |
| `splInfTpCd` | digits | ✅ | 공급 정보 유형 코드. 목록 응답의 `spl_inf_tp_cd` |
## 기본 흐름
1. 사용자 요청의 지역·공고 유형·상태를 추출한다.
2. 필터를 붙여 `/v1/lh-notice/search` 를 호출한다.
3. KST 오늘 날짜로 마감 여부(D-day)를 표시한다.
4. 공고 상위 3-5건(공고명 / 지역 / 공고일 / 마감일 / 상태 / 링크)을 요약한다.
5. 필요하면 `/v1/lh-notice/detail` 로 주택형별 공급 정보를 추가 조회한다.
## CLI 예시
공고중인 부산 영구임대 공고 목록:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
키워드 검색 (행복주택, 접수중만):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/search' \
--data-urlencode 'q=행복주택' \
--data-urlencode 'status=접수중'
```
공고 상세:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/lh-notice/detail' \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
## 응답 예시 (목록)
```json
{
"items": [
{
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"upp_ais_tp_cd": "06",
"ais_tp_cd": "09",
"ais_tp_cd_nm": "영구임대",
"cnp_cd_nm": "부산광역시",
"pan_ss": "공고중",
"pan_dt": "2026-04-21",
"clsg_dt": "2026-05-06",
"spl_inf_tp_cd": "051",
"ccr_cnnt_sys_ds_cd": "03",
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
}
],
"summary": { "page": 1, "page_size": 20, "returned_count": 1, "total_count": 1 },
"query": { "pan_ss": "공고중", "upp_ais_tp_cd": "06", "cnp_cd_nm": "부산광역시" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
## 실패 모드
- `400 bad_request`: `panSs` 값이 허용되지 않거나, 날짜 포맷이 잘못된 경우 등. 메시지를 그대로 사용자에게 노출한다.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없는 경우. 운영자가 키를 등록해야 한다.
- `502 upstream_error`: 공공데이터포털 서버 오류 또는 XML 에러 envelope. `upstream_code` 에 원본 코드가 들어간다 (예: `30` = 등록되지 않은 서비스키).
- `502 upstream_invalid_payload`: 응답이 JSON 이 아닌 경우 (보통 HTML 장애 페이지).
## Done when
- 공식 LH 공고 목록을 조회했다.
- 마감 여부를 KST 기준으로 판정해 표시했다.
- 각 결과에 공고 상세 링크(`detail_url`)를 포함했다.
- 필요한 경우 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 제공해 상세 조회로 이어갈 수 있도록 안내했다.
## 출처/참고
- LH 청약플러스 공고 목록: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
- 공공데이터포털 LH 임대공고문 정보 API: `https://www.data.go.kr/data/15058530/openapi.do`
- 레퍼런스 오픈소스: `heereal/Bunyang_MoeumZip` (Next.js 기반 LH/SH 공고 모음집 샘플 구현)

View file

@ -0,0 +1,138 @@
---
title: 네이버 뉴스 검색 가이드
description: k-skill-proxy 경유 네이버 검색 Open API 뉴스 검색으로 최신 기사 제목/요약/링크를 조회하는 방법
---
# 네이버 뉴스 검색 가이드
## 이 기능으로 할 수 있는 일
- 검색어 기반 네이버 뉴스 기사 후보 목록 조회
- 기사 제목, 본문 요약, 원문 링크, 네이버 뉴스 링크 정리
- 발행 시각(ISO-8601) 기준 최신순/관련도순 정렬
- `<b>` 하이라이트 태그와 HTML entity 를 proxy 쪽에서 제거한 깨끗한 텍스트 사용
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/naver-news/search` 이다. 사용자는 **네이버 개발자 센터 Client ID/Secret 을 발급받을 필요가 없다**. upstream key(`NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET`)는 프록시 서버에서만 주입한다.
네이버 검색 Open API 는 전체 검색 카테고리(뉴스/블로그/쇼핑 등) 를 합쳐 **하루 25,000 호출** 제한이 있다. 재시도 루프로 낭비하지 않는다.
본 스킬은 **기사 메타데이터와 요약만** 다룬다. 기사 본문 전체, 유료 기사, 로그인 필요 기사는 다루지 않는다.
## 먼저 필요한 것
- 인터넷 연결
- `curl` 또는 HTTP 호출이 가능한 도구
- (프록시 운영자 전용) `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 환경변수 - 네이버 개발자 센터(https://developers.naver.com/apps/#/register)에서 "검색" 권한 애플리케이션을 등록하고 발급받는다
## 지원 엔드포인트
| Route | 설명 |
| --- | --- |
| `GET /v1/naver-news/search` | 네이버 뉴스 검색. 제목/요약/링크/발행시각 정규화 |
### `/v1/naver-news/search` 파라미터
| 파라미터 | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| `q` / `query` / `keyword` | string | (필수) | 검색어. 2글자 이상 |
| `display` / `limit` / `size` | int | 10 | 반환 건수. 1 ~ 100 으로 clamp |
| `start` / `offset` | int | 1 | 검색 시작 위치(1-indexed). 최대 1000. `start + display - 1 > 1000` 이면 proxy 가 업스트림 호출 전에 `400 bad_request` 로 거절 |
| `sort` | string | `sim` | `sim`(관련도순) 또는 `date`(최신순). 그 외 값은 `sim` fallback |
## 기본 호출
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/naver-news/search' \
--data-urlencode 'q=삼성전자 실적' \
--data-urlencode 'display=10' \
--data-urlencode 'sort=date'
```
로컬 proxy:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/naver-news/search' \
--data-urlencode 'q=인공지능 규제' \
--data-urlencode 'display=5'
```
## 응답 예시
```json
{
"items": [
{
"rank": 1,
"title": "삼성전자 1분기 실적 발표",
"description": "삼성전자가 올해 1분기 실적을 발표했다. 영업이익은 전년 동기 대비 증가했다.",
"link": "https://n.news.naver.com/mnews/article/001/samsung",
"original_link": "https://news.example.com/samsung",
"pub_date": "Mon, 22 Apr 2026 09:30:00 +0900",
"pub_date_iso": "2026-04-22T00:30:00.000Z",
"source": "naver-openapi"
}
],
"query": {
"q": "삼성전자 실적",
"display": 10,
"start": 1,
"sort": "date"
},
"meta": {
"query": "삼성전자 실적",
"extraction": "naver-openapi",
"item_count": 1,
"total": 1234567,
"start": 1,
"display": 1,
"last_build_date": "Mon, 22 Apr 2026 10:00:00 +0900",
"sort": "date"
},
"upstream": {
"url": "https://openapi.naver.com/v1/search/news.json?query=...&display=10&start=1&sort=date",
"status_code": 200,
"content_type": "application/json;charset=UTF-8",
"provider": "naver-search-api"
},
"proxy": {
"name": "k-skill-proxy",
"cache": { "hit": false, "ttl_ms": 300000 },
"requested_at": "2026-04-22T01:00:00.000Z"
}
}
```
## 기본 흐름
1. 사용자 검색어를 확인한다. 없거나 2글자 미만이면 먼저 물어본다.
2. "최신순" 요청이면 `sort=date`, 그 외는 `sort=sim` 으로 호출한다.
3. 상위 3~5건을 제목·발행시각(KST)·요약·링크로 정리해 보여준다.
4. `original_link` 가 있으면 원문 링크를 우선 노출하고, 없으면 `link`(네이버 뉴스 redirect)를 안내한다.
5. `items` 가 비었거나 upstream 오류가 발생하면 재시도하지 말고 검색어를 좁혀 다시 물어본다.
## 실패 모드
- `400 bad_request`: 검색어 누락, 2글자 미만, 혹은 `start + display - 1 > 1000` 조합(네이버 1000-item search window 초과). 메시지를 그대로 사용자에게 노출한다.
- `503 upstream_not_configured`: 프록시 서버에 `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` 가 없는 경우. 운영자가 키를 등록해야 한다.
- `401 upstream_error` (`errorCode: 024`): 프록시 서버의 Client ID/Secret 잘못됨. 운영자 재발급 필요.
- `429 upstream_error` (`errorCode: 010`): 네이버 검색 API 일일 쿼터(25,000 호출/일) 초과. 재시도 루프 금지. 잠시 후 다시 시도.
- `502 upstream_error`: 네이버 API 5xx 또는 JSON 파싱 실패.
- proxy 는 upstream 실패 응답을 **캐시하지 않는다**(failure-aware cache). 다음 요청이 온전히 upstream 을 한 번 더 탄다.
## 운영 팁
- 사용자 요구가 "오늘", "최신" 이면 `sort=date` 로 호출하는 것이 보통 더 만족스럽다.
- `display` 가 클수록 네이버 API 쿼터를 빨리 소모한다. 기본 10 에서 벗어날 필요 없는 경우가 많다.
- `start + display - 1` 이 1000 을 넘는 조합(예: `start=1000&display=100`)은 proxy가 업스트림 호출 전에 `400 bad_request`(`"start + display exceeds Naver's 1000-item search window"`) 로 거절한다. 네이버 API 는 1000번째 아이템까지만 열람 가능하므로, 더 오래된 기사를 찾을 때는 검색어를 좁히는 것이 낫다.
- 뉴스 `link` 중복 제거는 쿼리 파라미터 순서, trailing slash, host 대소문자, URL fragment 를 무시한 **canonical URL** 기준으로 수행된다(`?a=1&b=2``?b=2&a=1`, `/article/42``/article/42/`, `news.example.com``NEWS.example.com`, `#comments` 같은 fragment 차이는 모두 같은 기사로 간주). 서로 다른 path(`/article/42` vs `/article/42/related`) 나 서로 다른 쿼리 값(`?a=1` vs `?a=2`)은 여전히 별개 기사로 남긴다. 실제 페이로드의 `link` 필드는 네이버가 돌려준 원문 그대로 노출한다.
- `pub_date` 는 RFC822 형식, `pub_date_iso` 는 UTC ISO-8601 이다. 사용자에게 보여줄 때는 KST(UTC+9) 로 변환한다.
- proxy route 는 public/read-only/no-auth 이며 5분 캐시 + 분당 60 회 rate limit 으로 남용을 막는다.
- 기사 원문 풀텍스트가 필요하면 이 스킬로는 얻을 수 없다. 사용자가 링크를 직접 방문하도록 안내한다.
## 출처/참고
- 네이버 검색 API 뉴스 검색 문서: https://developers.naver.com/docs/serviceapi/search/news/news.md
- 네이버 개발자 센터: https://developers.naver.com
- 레퍼런스 오픈소스: `isnow890/naver-search-mcp`, `kiyeonjeon21/naver-cli`

View file

@ -0,0 +1,112 @@
# HWP 레이아웃·IR 디버깅 (rhwp-advanced)
`rhwp-advanced` 스킬은 **업스트림 `rhwp` Rust CLI** 를 실제로 설치해서 HWP 파일의 **구조·레이아웃·버전 차이·썸네일**을 꺼내 보는 디버깅/검사 스킬이다. 편집은 하지 않는다.
- 편집 → [`rhwp-edit` 스킬](rhwp-edit.md) (`k-skill-rhwp` CLI + `@rhwp/core` WASM)
- 조회/변환 → [`hwp` 스킬](hwp.md) (kordoc)
## 준비
`rhwp` 네이티브 바이너리가 `PATH` 에 있어야 한다. 두 가지 설치 경로가 있다.
```bash
# (1) Rust toolchain 으로 직접 빌드 설치 — 서브커맨드 전체(PDF export 포함) 사용 가능
cargo install rhwp
# (2) 업스트림 릴리스 바이너리 — 플랫폼별 사전 빌드 제공 여부 확인
# https://github.com/edwardkim/rhwp/releases
```
설치 후 확인:
```bash
command -v rhwp && rhwp --help | head
```
## 서브커맨드 매트릭스 (v0.7.3 기준)
| 목적 | 서브커맨드 | 예시 |
| --- | --- | --- |
| 기본 메타 | `rhwp info` | `rhwp info sample.hwp` |
| 페이지 SVG 렌더(디버그 오버레이) | `rhwp export-svg` | `rhwp export-svg sample.hwp -o out/ -p 0 --debug-overlay` |
| 페이지 PDF 렌더 (네이티브 빌드 한정) | `rhwp export-pdf` | `rhwp export-pdf sample.hwp -o out.pdf` |
| 문서 IR 구조 덤프 | `rhwp dump` | `rhwp dump sample.hwp -s 0 -p 3` |
| 페이지네이션 덤프 | `rhwp dump-pages` | `rhwp dump-pages sample.hwp -p 2` |
| 원시 레코드 덤프 | `rhwp dump-records` | `rhwp dump-records sample.hwp` |
| 번호·글머리표·개요 진단 | `rhwp diag` | `rhwp diag sample.hwp` |
| 두 파일 IR 비교 | `rhwp ir-diff` | `rhwp ir-diff a.hwpx b.hwp > ir-diff.txt` |
| PrvImage 썸네일 추출 | `rhwp thumbnail` | `rhwp thumbnail sample.hwp -o thumb.png` |
| 배포용(읽기전용) 잠금 해제 | `rhwp convert` | `rhwp convert locked.hwp unlocked.hwp` |
| 표 템플릿 신규 문서 생성 | `rhwp gen-table` | `rhwp gen-table out.hwp` |
> **편집 서브커맨드는 없다.** v0.7.3 기준 업스트림 `rhwp` CLI 에는 `edit` / `insert-text` / `save` 같은 in-place 편집 명령이 없다. 편집은 `rhwp-edit` 스킬 (`k-skill-rhwp` CLI) 이 맡는다.
## 자주 쓰는 플로우
### SVG 렌더가 이상하게 보일 때
디버그 오버레이를 붙인 SVG 를 뽑아 문단/표 경계 라벨(`s{sec}:pi={idx} y={y}`) 로 문제 위치를 좁힌다.
```bash
mkdir -p out
rhwp export-svg sample.hwp -o out/ -p 0 --debug-overlay
open out/page-0.svg
```
### 페이지 레이아웃이 궁금할 때
```bash
rhwp dump-pages sample.hwp -p 2
```
### 표 구조/ParaShape 가 이상할 때
```bash
rhwp dump sample.hwp -s 0 -p 3
```
### 두 버전 비교
```bash
rhwp ir-diff draft-v1.hwp draft-v2.hwp > ir-diff.txt
wc -l ir-diff.txt
```
### 썸네일 꺼내기
```bash
rhwp thumbnail sample.hwp -o cover.png
# 또는 data URI 로 바로 쓰려면
rhwp thumbnail sample.hwp --data-uri
```
### 배포용(읽기전용) 잠금 해제
```bash
rhwp convert locked.hwp unlocked.hwp
# 이후 편집은 `k-skill-rhwp` CLI (rhwp-edit 스킬) 로
```
## 검증 포인트
- `export-svg`: `-o` 경로에 `page-N.svg` 생성, 뷰어로 열어 텍스트·도형·오버레이 확인.
- `dump*`: stdout 에 수십 줄 이상 구조 출력.
- `ir-diff`: 파일 간 차이가 없으면 거의 빈 출력, 있으면 줄 단위 delta.
- `thumbnail`: PNG 가 이미지 뷰어에서 정상 표시.
- `convert`: 산출 파일을 다시 `rhwp info` 로 열었을 때 read-only 표시 해제.
## 제약 / 주의
- **PDF export 는 네이티브 빌드 한정**. `@rhwp/core` WASM 경로에서는 불가. `cargo install rhwp` 로 설치한 네이티브 바이너리로 돌려야 한다.
- **HWPX 저장 비활성화(rhwp #196)**: `rhwp` 자체가 HWPX → HWPX round-trip 을 막아 둔 상태. 저장이 필요한 작업은 HWP 5.x 로만.
- **버전 드리프트**: rhwp 는 빠르게 개발 중이다(v0.7.3 2026-04-19). 각 서브커맨드의 flag 는 `rhwp <subcommand> --help` 로 먼저 확인.
- **개인정보 보호**: `dump*` / `ir-diff` 출력에 원문 텍스트가 그대로 섞일 수 있다. PR/보고서에 붙일 때 필요 구간만 인용하고 민감한 본문은 마스킹.
- **Windows GUI/보안모듈**: `rhwp` 는 파일 포맷 엔진이다. 한컴 GUI 자동화·보안모듈 우회는 범위 밖.
## 참고
- 업스트림: https://github.com/edwardkim/rhwp
- CLI 서브커맨드 소스: https://github.com/edwardkim/rhwp/blob/main/src/main.rs
- 편집 경로(이 repo): [`rhwp-edit`](rhwp-edit.md)
- 조회 경로(이 repo): [`hwp`](hwp.md)
- 스킬 정의: [`rhwp-advanced/SKILL.md`](../../rhwp-advanced/SKILL.md)

131
docs/features/rhwp-edit.md Normal file
View file

@ -0,0 +1,131 @@
# HWP 문서 편집 (rhwp-edit)
`rhwp-edit` 스킬은 **`.hwp` 문서를 실제로 편집**하는 스킬이다. 본문에 텍스트를 넣고, 표를 만들고, 특정 셀 내용을 바꾸고, 전체 치환을 하는 식의 round-trip 편집을 Node CLI 한 줄로 돌린다.
엔진은 이 레포에서 새로 발행하는 npm 패키지 **`k-skill-rhwp`** 이다. `k-skill-rhwp` 는 업스트림 `@rhwp/core` (Rust + WebAssembly, MIT, [edwardkim/rhwp](https://github.com/edwardkim/rhwp)) 의 편집 API 를 얇게 래핑해 `insert-text`, `delete-text`, `replace-all`, `create-table`, `set-cell-text`, `render` 같은 CLI 서브커맨드로 노출한다. Rust toolchain 설치는 필요 없고, 번들된 WASM 이 그대로 돌아간다.
이 스킬은 **편집 전용**이다.
- 조회/Markdown·JSON 변환·양식 필드 추출은 → [`hwp` 스킬](hwp.md) (kordoc)
- 페이지 SVG 디버깅·IR 덤프·ir-diff·썸네일·배포용 문서 잠금 해제 는 → [`rhwp-advanced` 스킬](rhwp-advanced.md) (업스트림 `rhwp` Rust CLI)
## 준비
- Node.js 18+
- `k-skill-rhwp` 설치 — 셋 중 하나
```bash
# 일회성
npx --yes k-skill-rhwp --help
# 전역
npm install -g k-skill-rhwp
# 프로젝트 로컬
npm install k-skill-rhwp
```
- `@rhwp/core@^0.7.3``k-skill-rhwp` 가 dependency 로 함께 끌어온다. 별도 설치 불필요.
- 업스트림 Rust `rhwp` 바이너리는 이 스킬이 요구하지 않는다(`rhwp-advanced` 스킬에서 따로 설치).
## 주요 시나리오
### 1) 빈 HWP 한 장 만들기
```bash
npx k-skill-rhwp create-blank ./out/blank.hwp
# => { "bytesWritten": 12800, "outputPath": "/abs/path/out/blank.hwp" }
```
### 2) 본문 첫 문단 맨 앞에 제목 삽입
```bash
npx k-skill-rhwp insert-text ./draft.hwp ./out/draft-with-title.hwp \
--section 0 --paragraph 0 --offset 0 \
--text "2026년 오픈소스 AI·SW 지원사업 신청서"
```
### 3) `2025``2026` 일괄 치환
```bash
npx k-skill-rhwp replace-all ./draft.hwp ./out/2026.hwp \
--query 2025 --replacement 2026
```
대소문자 구분이 필요하면 `--case-sensitive` 를 붙인다. 길이가 다른 치환(예: `2026``이천이십칠`)도 문제없이 동작한다.
**스코프 주의** — `replace-all`**본문(body) 문단만** 스캔한다. 업스트림 `searchText` 가 본문만 커버하기 때문에 같은 스코프를 따른다. 표 셀, 머리말/꼬리말, 각주 본문의 텍스트는 `replace-all` 이 건드리지 않는다. 셀 내용을 바꾸려면 아래 4) 의 `set-cell-text` 를 쓴다.
**Unicode 대소문자 무시 주의** — 기본(`--case-sensitive` 없이) 모드는 `String.prototype.toLowerCase()` 의 UTF-16 길이 보존을 전제한다. 본문이나 쿼리에 터키어 `İ`(U+0130) 처럼 소문자화 시 길이가 늘어나는 문자가 섞여 있으면, 오프셋 드리프트로 인한 조용한 손상을 막기 위해 `replace-all` 은 exit code 1 과 함께 `case-insensitive matching is unsafe because case folding changes the UTF-16 length` 를 돌려준다. 이 경우 `--case-sensitive` 로 재실행하거나 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않는다.
### 4) 표 추가 후 특정 셀 채우기
`create-table` 은 만든 표의 `paraIdx` / `controlIdx` 를 같이 돌려준다. 그 두 값을 `set-cell-text` 에 그대로 넣으면 된다.
```bash
# (1) 3행 4열 표 삽입
npx k-skill-rhwp create-table ./report.hwp ./out/with-table.hwp \
--section 0 --paragraph 1 --offset 0 --rows 3 --cols 4
# (2) 위 결과의 paraIdx / controlIdx 로 (0,0) 셀 채우기
npx k-skill-rhwp set-cell-text ./out/with-table.hwp ./out/with-header.hwp \
--section 0 --parent-paragraph <paraIdx> --control <controlIdx> \
--cell 0 --text "합계"
```
### 5) 편집 전 구조 조회
좌표를 잘못 주면 WASM 이 "구역 인덱스 … 범위 초과" 같은 오류로 거절한다. 편집 전에 먼저 구조를 확인한다.
```bash
npx k-skill-rhwp info ./draft.hwp
npx k-skill-rhwp list-paragraphs ./draft.hwp --section 0
npx k-skill-rhwp search ./draft.hwp --query "2025"
```
`search``replace-all` 과 마찬가지로 **본문 문단만** 스캔한다. 표 셀/머리말/꼬리말/각주 안의 텍스트는 `search` 가 찾지 않는다. 셀 내용은 `info` 또는 `list-paragraphs` 로 표 좌표(`paraIdx` / `controlIdx`) 를 확인한 뒤 `set-cell-text` 로 직접 쓴다.
## Node API
CLI 가 아니라 스크립트에서 직접 호출할 수도 있다.
```js
const { insertText, createTable, setCellText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./draft.hwp",
output: "./draft-with-title.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 신청서"
});
const info = await getDocumentInfo("./draft-with-title.hwp");
console.log(info.sections[0].paragraphs[0].length);
```
WASM 은 첫 호출 때 한 번만 초기화되고, Node 기본 환경에서도 동작하도록 `globalThis.measureTextWidth` shim 이 자동으로 설치된다. 픽셀 정밀 레이아웃이 필요하면 `node-canvas` 기반 shim 을 첫 호출 전에 주입한다.
## 검증 포인트
- 편집 직후 `k-skill-rhwp info <output>` 결과의 `sections[N].paragraphs[M].length` 가 기대와 일치한다.
- 새 표는 `sections[N].paragraphCount` 를 최소 1 이상 증가시킨다(위치에 따라 표 내부 문단도 합산됨).
- `k-skill-rhwp render <output> --page 0 --format svg``<svg>` 로 시작하는 문자열을 반환한다.
- 출력 파일 크기는 blank 기준 최소 12 KB 이상, 편집 후에도 비슷하거나 더 크다.
- 원본 파일 경로는 CLI 가 절대 덮어쓰지 않는다(항상 별도 `<output>` 를 지정한다).
## 제약 / 주의
- **HWPX 원본 저장은 업스트림 `rhwp``#196` 으로 비활성화 상태**다. HWPX 파일을 입력으로 줘도 저장은 HWP 5.x 바이너리로만 된다. HWPX 출력이 반드시 필요하면 `hwp` 스킬의 kordoc `markdownToHwpx` 경로를 사용한다.
- **rhwp v0.7.x 는 베타**이다. 복잡한 표/이미지/차트/양식 필드가 많은 실제 사업 신청서를 round-trip 할 때 드물게 형식 손실이 발생할 수 있다. 편집 직후 `info` + `render` 로 빠른 육안 검증을 권장한다.
- **배포용(읽기전용) 문서**`rhwp-edit` CLI 는 아직 `convert` 를 노출하지 않는다. 잠금 해제는 `rhwp-advanced` 스킬의 `rhwp convert` 를 먼저 거친다.
- **개인정보가 포함된 원본** — 편집 산출물을 레포에 커밋하지 말고, 로그에 남길 때 본문 텍스트는 요약·마스킹한다.
- **한컴 보안모듈 / Windows GUI 자동화** — 이 스킬은 파일 포맷 엔진을 다룰 뿐, GUI 제어를 하지 않는다.
## 참고
- `k-skill-rhwp` 패키지 소스: `packages/k-skill-rhwp/`
- 업스트림 rhwp: https://github.com/edwardkim/rhwp
- `@rhwp/core` npm: https://www.npmjs.com/package/@rhwp/core
- 스킬 정의: [`rhwp-edit/SKILL.md`](../../rhwp-edit/SKILL.md)

View file

@ -45,6 +45,8 @@ k-skill-setup 스킬을 사용해서 공통 설정을 진행해줘.
```bash
npx --yes skills add <owner/repo> \
--skill hwp \
--skill rhwp-edit \
--skill rhwp-advanced \
--skill kbo-results \
--skill kbl-results \
--skill kleague-results \

View file

@ -20,6 +20,7 @@
- 한국 법령 검색 스킬 출시
- 한국 개인정보처리방침·이용약관 스킬 출시 (kimlawtech/korean-privacy-terms Apache-2.0 업스트림 기반 thin wrapper)
- 한국 부동산 실거래가 조회 스킬 출시
- LH 청약 공고문 조회 스킬 출시
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
- 장학금 검색 및 조회 스킬 출시
@ -148,8 +149,9 @@
#### HWP 고급 편집 자동화
- 장점: 기본 변환/추출은 이미 `hwp` 스킬로 다루고 있어 확장 가치가 높다
- 보류 이유: 고급 양식, 배포용 서식, 보안 모듈까지 포함한 Windows 직접 제어는 추가 검증이 더 필요하다
- **업데이트(2026-04)**: `rhwp-edit` / `rhwp-advanced` 스킬이 추가되어 `@rhwp/core` WASM 기반 본문/표/셀 편집과 업스트림 `rhwp` Rust CLI 기반 레이아웃 디버깅·IR 덤프·ir-diff·썸네일·배포용 문서 잠금 해제가 shipped 상태다(#155). `rhwp` 는 파일 포맷 엔진이고 **한컴 Windows GUI/보안모듈 직접 제어는 여전히 범위 밖**.
- 장점: 기본 변환/추출은 `hwp` 스킬(kordoc), 바이너리 편집은 `rhwp-edit`, 고급 검사는 `rhwp-advanced` 로 분업이 분명해져 확장 지점이 명확하다
- 계속 보류되는 항목: 고급 양식·배포용 서식·보안 모듈까지 포함한 Windows 직접 제어(한컴 오피스 GUI/보안모듈)는 추가 검증이 더 필요하다
#### 당근 자동 거래

View file

@ -27,6 +27,11 @@
- Oracle's Elixir data glossary: https://oracleselixir.com/tools/downloads
- `kordoc`: https://github.com/chrisryugj/kordoc
- `pdfjs-dist`: https://www.npmjs.com/package/pdfjs-dist
- `rhwp` upstream (Rust + WebAssembly HWP parser/renderer/editor, MIT, by Edward Kim): https://github.com/edwardkim/rhwp
- `rhwp` CLI source (upstream subcommand truth table): https://github.com/edwardkim/rhwp/blob/main/src/main.rs
- `@rhwp/core` npm (WASM bindings used by `k-skill-rhwp`): https://www.npmjs.com/package/@rhwp/core
- `@rhwp/editor` npm (upstream iframe editor — not wrapped by this repo, documented for reference): https://www.npmjs.com/package/@rhwp/editor
- rhwp HWPX-save-disabled issue #196 (data-safety gate until #197 ships): https://github.com/edwardkim/rhwp/issues/196
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- korean-privacy-terms upstream: https://github.com/kimlawtech/korean-privacy-terms (Apache-2.0)
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
@ -56,6 +61,11 @@
- 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
- LH 청약플러스 공고 목록: https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026
- 공공데이터포털 한국토지주택공사 임대공고문 정보 API: https://www.data.go.kr/data/15058530/openapi.do
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
- 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

View file

@ -1,6 +1,6 @@
---
name: hwp
description: Use kordoc for agent-native HWP/HWPX document parsing, JSON extraction, diffing, form-field extraction, and Markdown→HWPX reverse conversion.
description: Use kordoc for agent-native HWP/HWPX document parsing, JSON extraction, diffing, form-field extraction, and Markdown→HWPX reverse conversion (read/convert only — for binary editing use rhwp-edit).
license: MIT
metadata:
category: documents
@ -17,6 +17,10 @@ metadata:
이 스킬의 기본 엔진은 **항상 `kordoc`** 이다. 문서 변환, 비교, 필드 추출, 역변환까지 같은 도구로 일관되게 처리한다.
> **스킬 라우팅** — 이 `hwp` 스킬은 **조회/변환(read-only)** 전용이다.
> HWP 바이너리 **편집**(본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all)은 [`rhwp-edit`](../rhwp-edit/SKILL.md) 스킬이,
> 레이아웃 디버깅·IR 덤프·썸네일·배포용 문서 잠금 해제 같은 **고급 검사**는 [`rhwp-advanced`](../rhwp-advanced/SKILL.md) 스킬이 맡는다.
## When to use
- "이 HWP 파일을 Markdown으로 바꿔줘"
@ -31,6 +35,8 @@ metadata:
- OCR이 필수인데 OCR provider 연결이 전혀 없는 이미지 기반 PDF만 있는 경우
- `.docx`, `.xlsx`, `.pdf` 만 다루더라도 문서 파싱 자체가 아니라 편집기 GUI 자동화가 필요한 경우
- 원본 프로그램의 실시간 UI 제어가 반드시 필요한 경우
- **본문 텍스트 직접 삽입·삭제·치환 또는 표 구조 변경**`rhwp-edit` 스킬의 `k-skill-rhwp` CLI 를 사용한다.
- **페이지 SVG 렌더 디버깅·IR 덤프·ir-diff·썸네일 추출**`rhwp-advanced` 스킬의 업스트림 `rhwp` CLI 를 사용한다.
## Prerequisites

416
k-dart/SKILL.md Normal file
View file

@ -0,0 +1,416 @@
---
name: k-dart
description: 금융감독원 전자공시시스템(DART) OpenAPI로 공시검색, 기업개황, 재무제표, 주요사항보고서를 조회한다. 사용자의 API_K_DART 환경변수를 직접 사용한다.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# k-dart — 금감원 DART 전자공시 조회
## What this skill does
`API_K_DART` 환경변수에 담긴 인증키로 DART OpenAPI(`https://opendart.fss.or.kr/api/`)를 직접 호출해 공시·재무·주요사항 정보를 조회한다. 프록시를 거치지 않는다.
## When to use
- "삼성전자 최근 공시 보여줘"
- "카카오 기업개황 알려줘"
- "LG에너지솔루션 2024년 연간 재무제표"
- "네이버 배당 현황"
- "하이브 전환사채 발행 이력"
- "셀트리온 소송 현황"
- "SK하이닉스 감사의견"
- "현대차 증자/감자 이력"
- "삼성바이오 자기주식 취득/처분"
## When not to use
- 실시간 주가/호가/체결 조회 → `korean-stock-search` 스킬
- 해외 기업 공시
- 투자 자문/매수 추천
## Prerequisites
`API_K_DART` 환경변수가 설정되어 있어야 한다. 키 발급: <https://opendart.fss.or.kr/uss/umt/EgovMberInsertView.do>
## corp_code 확보 절차
DART API 대부분은 `corp_code`(8자리 고유번호)를 요구한다. 사용자가 종목명이나 종목코드(6자리)만 제공하면:
1. **고유번호 전체 목록(`corpCode.xml`)을 다운로드**해 회사명 또는 종목코드로 `corp_code`를 조회한다:
**macOS / Linux (bash):**
```bash
# ZIP 다운로드 → 압축 해제 (이미 있으면 생략)
[ -f /tmp/dart_corp/CORPCODE.xml ] || {
curl -fsS -o /tmp/dart_corp.zip \
"https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$API_K_DART"
mkdir -p /tmp/dart_corp && unzip -o /tmp/dart_corp.zip -d /tmp/dart_corp
}
# 회사명 또는 종목코드로 corp_code 검색 (상장사만)
grep -B2 -A3 '삼성전자' /tmp/dart_corp/CORPCODE.xml | awk '
/<corp_code>/{code=$0; gsub(/.*<corp_code>|<\/corp_code>.*/,"",code)}
/<corp_name>/{name=$0; gsub(/.*<corp_name>|<\/corp_name>.*/,"",name)}
/<stock_code>[0-9]/{stock=$0; gsub(/.*<stock_code>|<\/stock_code>.*/,"",stock); print code, stock, name}
'
# 출력: 00126380 005930 삼성전자
```
**Windows (PowerShell):**
```powershell
# ZIP 다운로드 → 압축 해제 (이미 있으면 생략)
$dartDir = "$env:TEMP\dart_corp"
if (-not (Test-Path "$dartDir\CORPCODE.xml")) {
Invoke-WebRequest "https://opendart.fss.or.kr/api/corpCode.xml?crtfc_key=$env:API_K_DART" -OutFile "$dartDir.zip"
New-Item -ItemType Directory -Path $dartDir -Force | Out-Null
Expand-Archive "$dartDir.zip" -DestinationPath $dartDir -Force
}
# 회사명 또는 종목코드로 corp_code 검색 (상장사만)
[xml]$xml = Get-Content "$dartDir\CORPCODE.xml"
$xml.result.list | Where-Object { $_.corp_name -like '*삼성전자*' -and $_.stock_code.Trim() -ne '' } |
Select-Object corp_code, stock_code, corp_name
# 출력: 00126380 005930 삼성전자
```
2. 획득한 `corp_code`로 나머지 API 호출
> **참고:** `/tmp/dart_corp/CORPCODE.xml`이 이미 있으면 재다운로드 없이 재사용한다. 파일은 약 30MB이며 전체 법인 목록(상장+비상장)을 포함한다. `corpCode.xml`에 회사명·종목코드·고유번호가 모두 포함되어 있으므로 별도 스킬 연계 없이 단독으로 corp_code를 확보할 수 있다.
## Supported endpoints
모든 요청은 `GET https://opendart.fss.or.kr/api/{endpoint}.json?crtfc_key=$API_K_DART&...` 형식이다.
### 1. 공시검색
```http
GET /api/list.json?crtfc_key={key}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
[&corp_code={code}]
[&last_reprt_at=Y|N] [&pblntf_ty=A..J] [&pblntf_detail_ty=...]
[&corp_cls=Y|K|N|E] [&sort=date|crp|rpt] [&sort_mth=asc|desc]
[&page_no=1] [&page_count=10]
```
공식 가이드(DS001/2019001) 요청 인자 정리 (필수여부·기본값·허용값은 공식 표 기준, 식별자는 코드 폰트로 표기):
| 요청키 | 명칭 | 타입 | 필수여부 | 값설명 |
|---|---|---|---|---|
| `crtfc_key` | API 인증키 | STRING(40) | Y | 발급받은 인증키(40자리) |
| `corp_code` | 고유번호 | STRING(8) | N | 공시대상회사의 고유번호(8자리). ※ 개발가이드 > 공시정보 > 고유번호 참고 |
| `bgn_de` | 시작일 | STRING(8) | Y | 검색시작 접수일자(YYYYMMDD). 1) 기본값: 종료일(`end_de`). 2) **고유번호(`corp_code`)가 없는 경우 검색기간은 3개월로 제한** |
| `end_de` | 종료일 | STRING(8) | Y | 검색종료 접수일자(YYYYMMDD). 1) 기본값: 당일 |
| `last_reprt_at` | 최종보고서 검색여부 | STRING(1) | N | 최종보고서만 검색여부(Y or N). 1) 기본값: N (정정이 있는 경우 최종정정만 검색) |
| `pblntf_ty` | 공시유형 | STRING(1) | N | A=정기공시, B=주요사항보고, C=발행공시, D=지분공시, E=기타공시, F=외부감사관련, G=펀드공시, H=자산유동화, I=거래소공시, J=공정위공시 |
| `pblntf_detail_ty` | 공시상세유형 | STRING(4) | N | (※ 상세 유형 참조: `pblntf_detail_ty`) |
| `corp_cls` | 법인구분 | STRING(1) | N | Y(유가), K(코스닥), N(코넥스), E(기타). ※ 없으면 전체조회, **복수조건 불가** |
| `sort` | 정렬 | STRING(4) | N | 접수일자: `date` / 회사명: `crp` / 보고서명: `rpt`. ※ 기본값: `date` |
| `sort_mth` | 정렬방법 | STRING(4) | N | 오름차순(`asc`), 내림차순(`desc`). ※ 기본값: `desc` |
| `page_no` | 페이지 번호 | STRING(5) | N | 페이지 번호 (1~n). 기본값: 1 |
| `page_count` | 페이지 별 건수 | STRING(3) | N | 페이지당 건수 (1~100). 기본값: 10, 최대값: 100 |
`pblntf_detail_ty` 자주 쓰는 코드 예시: A001=사업보고서, A002=반기보고서, A003=분기보고서, B001=주요사항보고서, F001=감사보고서, F002=연결감사보고서, D001=주식등의대량보유상황보고서.
> **주의:** DART OpenAPI `list.json` 의 공식 요청 파라미터 표에 `corp_name` 은 존재하지 않는다. 회사명을 기준으로 특정 기업 공시만 좁혀 보려면 위 "corp_code 확보 절차"로 먼저 `corp_code`(8자리 고유번호)를 얻은 뒤 호출한다.
### 2. 기업개황
```http
GET /api/company.json?crtfc_key={key}&corp_code={code}
```
### 3. 재무제표 (단일회사 전체 재무제표)
```http
GET /api/fnlttSinglAcntAll.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}&fs_div={OFS|CFS}
```
`reprt_code`: 11013(1분기), 11012(반기), 11014(3분기), 11011(사업보고서)
`fs_div`: OFS(개별), CFS(연결)
### 4. 증자(감자) 현황
```http
GET /api/irdsSttus.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}
```
### 5. 배당에 관한 사항
```http
GET /api/alotMatter.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}
```
### 6. 자기주식 취득 및 처분 현황
```http
GET /api/tesstkAcqsDspsSttus.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}
```
### 7. 회계감사인의 명칭 및 감사의견
```http
GET /api/accnutAdtorNmNdAdtOpinion.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}
```
### 8. 직원 현황
```http
GET /api/empSttus.json?crtfc_key={key}&corp_code={code}&bsns_year={YYYY}&reprt_code={code}
```
부문별·성별 정규직/계약직 인원수, 평균 근속연수, 1인 평균 급여 등을 반환한다.
### 9. 유무상증자 결정
```http
GET /api/pifricDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
> **주의:** `pifricDecsn.json`**유무상증자 결정** 전용 endpoint다. 유상증자만 따로 조회하려면 `piicDecsn.json` (유상증자 결정), 무상증자만 따로 조회하려면 `fricDecsn.json` (무상증자 결정)을 호출한다.
### 10. 소송 등의 제기
```http
GET /api/lwstLg.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
### 11. 해외 증권시장 주권등 상장 결정
```http
GET /api/ovLstDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
### 12. 해외 증권시장 주권등 상장폐지 결정
```http
GET /api/ovDlstDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
### 13. 전환사채권 발행결정
```http
GET /api/cvbdIsDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
### 14. 교환사채권 발행결정
```http
GET /api/exbdIsDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
### 15. 회사분할합병 결정
```http
GET /api/cmpDvmgDecsn.json?crtfc_key={key}&corp_code={code}&bgn_de={YYYYMMDD}&end_de={YYYYMMDD}
```
## Example requests
공시검색 (특정 기업, 삼성전자):
```bash
# 1. 먼저 위 "corp_code 확보 절차"로 corp_code(예: 삼성전자=00126380) 획득
# 2. corp_code로 기간 내 공시 조회
curl -fsS --get 'https://opendart.fss.or.kr/api/list.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bgn_de=20260101' \
--data-urlencode 'end_de=20260419' \
--data-urlencode 'page_count=5'
```
공시검색 (전체 시장 최근 공시, corp_code 미지정 — 공식 spec상 검색 기간 ≤ 3개월):
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/list.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'bgn_de=20260301' \
--data-urlencode 'end_de=20260419' \
--data-urlencode 'pblntf_ty=A' \
--data-urlencode 'corp_cls=Y' \
--data-urlencode 'page_count=10'
```
기업개황:
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/company.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380'
```
재무제표 (연결, 사업보고서):
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/fnlttSinglAcntAll.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bsns_year=2024' \
--data-urlencode 'reprt_code=11011' \
--data-urlencode 'fs_div=CFS'
```
배당 현황:
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/alotMatter.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bsns_year=2024' \
--data-urlencode 'reprt_code=11011'
```
직원 현황 (사업보고서 기준):
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/empSttus.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bsns_year=2024' \
--data-urlencode 'reprt_code=11011'
```
유무상증자 결정 (`pifricDecsn.json` — 유상증자만 보려면 `piicDecsn.json`):
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/pifricDecsn.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bgn_de=20200101' \
--data-urlencode 'end_de=20260419'
```
전환사채 발행결정:
```bash
curl -fsS --get 'https://opendart.fss.or.kr/api/cvbdIsDecsn.json' \
--data-urlencode "crtfc_key=$API_K_DART" \
--data-urlencode 'corp_code=00126380' \
--data-urlencode 'bgn_de=20200101' \
--data-urlencode 'end_de=20260419'
```
## Response shape
### DART 공통 응답 구조
모든 응답은 `status``message` 필드를 포함한다:
```json
{
"status": "000",
"message": "정상",
"list": [ ... ]
}
```
### 상태코드
| status | 의미 |
|--------|------|
| 000 | 정상 |
| 010 | 등록되지 않은 키 |
| 011 | 사용할 수 없는 키 |
| 012 | 접근할 수 없는 IP |
| 013 | 조회된 데이터 없음 |
| 014 | 파일이 존재하지 않음 |
| 020 | 요청 제한 초과 (공식 가이드: 일반적으로 20,000건 이상 요청 시 발생. 키별 별도 한도가 설정된 경우에도 동일 코드가 반환될 수 있음) |
| 021 | 조회 가능한 회사 개수 초과 (최대 100개) |
| 100 | 필드 오류 (필드의 부적절한 값) |
| 800 | 원천 시스템 점검 중 |
| 900 | 정의되지 않은 오류 |
### 공시검색 응답 예시
```json
{
"status": "000",
"message": "정상",
"page_no": 1,
"page_count": 5,
"total_count": 142,
"total_page": 29,
"list": [
{
"corp_code": "00126380",
"corp_name": "삼성전자",
"stock_code": "005930",
"corp_cls": "Y",
"report_nm": "[기재정정]사업보고서 (2024.12)",
"rcept_no": "20250401000123",
"flr_nm": "삼성전자",
"rcept_dt": "20250401",
"rm": ""
}
]
}
```
### 기업개황 응답 예시
```json
{
"status": "000",
"message": "정상",
"corp_code": "00126380",
"corp_name": "삼성전자",
"corp_name_eng": "SAMSUNG ELECTRONICS CO.,LTD",
"stock_name": "삼성전자",
"stock_code": "005930",
"ceo_nm": "한종희, 경계현",
"corp_cls": "Y",
"jurir_no": "1301110006246",
"bizr_no": "1248100998",
"adres": "경기도 수원시 영통구 삼성로 129",
"hm_url": "www.samsung.com",
"ir_url": "",
"phn_no": "031-200-1114",
"induty_code": "264",
"est_dt": "19690113",
"acc_mt": "12"
}
```
## Response policy
- `status``"000"`이 아니면 에러 메시지를 사용자에게 안내한다.
- `status: "013"` (조회된 데이터 없음) 이면 기간/보고서 종류/`corp_code` 를 재확인하도록 안내한다.
- `status: "020"` (요청 제한 초과)이면 호출 한도 도달 가능성을 안내한다. 공식 가이드는 "일반적으로 20,000건 이상 요청 시" 발생한다고만 명시하며, 키별로 별도 한도가 설정되어 있으면 다른 임계치에서도 발생할 수 있음을 함께 알린다. 잠시 후 재시도를 권한다.
- 종목명만 알고 있다면 위 "corp_code 확보 절차"의 `corpCode.xml` 파싱으로 먼저 `corp_code`를 확보한 뒤 후속 API를 호출한다 (DART OpenAPI `list.json` 의 공식 요청 파라미터 표에 `corp_name` 은 존재하지 않는다).
- 재무제표 조회 시 `reprt_code` 를 사용자가 지정하지 않으면 사업보고서(11011)를 기본값으로 사용한다.
- `fs_div`를 지정하지 않으면 연결(CFS)을 기본값으로 사용한다.
- 주요사항보고서(9~15번)는 날짜 범위가 필요하다. 사용자가 기간을 지정하지 않으면 최근 1년을 기본으로 한다.
- 숫자는 읽기 쉬운 단위(억, 조, 주)로 풀어주되 원본 수치도 유지한다.
- 답변 말미에 "금감원 DART 공시 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
## Keep the answer compact
- 공시검색: 공시명 / 접수일 / 제출인 위주로 최근 5~10건
- 기업개황: 회사명 / 대표자 / 업종 / 주소 / 결산월
- 재무제표: 매출액 / 영업이익 / 당기순이익 / 자산총계 / 부채총계 / 자본총계 핵심 항목
- 주요사항보고서: 핵심 결정 내용과 일자를 요약
## Failure modes
- `API_K_DART` 환경변수 미설정 → 키 발급 안내 후 중단
- `status``"000"` → 상태코드표 참고해 에러 안내
- `corp_code`를 찾을 수 없음 → 회사명 재확인 요청
- 해당 기간/보고서에 데이터 없음 → 기간 또는 `reprt_code` 변경 안내
## Done when
- `API_K_DART` 존재를 확인했다.
- 사용자 요청에 맞는 endpoint를 호출해 결과를 정리했다.
- 필요 시 `corp_code`를 먼저 확보한 뒤 후속 조회를 수행했다.
- 금감원 DART 공시 데이터 기준임을 짧게 남겼다.
## Notes
- 공식 데이터 출처: [DART OpenAPI](https://opendart.fss.or.kr/intro/main.do)
- 이 스킬은 read-only 조회 전용이다.
- DART API 요청 한도: 공식 가이드(`020` 메시지 설명)는 "**일반적으로 20,000건 이상의 요청** 에 대해 `020` (요청 제한 초과)이 발생하며, **키별로 별도 한도가 설정된 경우** 다른 임계치에서도 동일 코드가 반환될 수 있다"고 명시한다. 분당 throttle 등 세부 제약 수치는 공개 가이드에 별도로 게시되어 있지 않다. 본인 키의 정확한 사용 현황은 로그인 후 [OpenDART 이용현황](https://opendart.fss.or.kr/mng/apiUsageStatusView.do) 페이지에서 확인할 수 있다.

View file

@ -0,0 +1,191 @@
---
name: korean-slang-writing
description: Use curated Korean trending-slang candidates plus best-effort Namu Wiki lookups to write witty Korean text with up-to-date slang, with conservative safety and freshness guardrails.
license: MIT
metadata:
category: writing
locale: ko-KR
phase: v1
---
# Korean Slang Writing
## What this skill does
사용자가 "유행어 섞어서", "요즘 말투로", "밈스럽게", "재치있게" 같은 요청을 했을 때, **검증된 한국 유행어 후보**를 먼저 뽑고 필요하면 나무위키 원문 요약을 확인해 한국어 문장·홍보문·SNS 게시글·댓글을 유행어 느낌으로 작성한다.
- 스킬은 **데이터와 조회 도구**만 제공한다: 큐레이션된 시드 인덱스 검색 + 나무위키 best-effort 요약.
- 실제 문장 작성은 **에이전트(LLM)** 가 스킬이 돌려준 후보 중에서 사용자 의도에 맞는 표현을 골라 한다.
- 시드 인덱스 출처는 [나무위키 분류:유행어](https://namu.wiki/w/%EB%B6%84%EB%A5%98:%EC%9C%A0%ED%96%89%EC%96%B4) 를 기준으로 수작업 큐레이션한다.
- risky 수준의 혐오·차별·집단 조롱 가능성이 있는 표현은 시드에 넣지 않는다.
## When to use
- "SNS 홍보 글 유행어 섞어서 써줘"
- "댓글을 요즘 말투로 바꿔줘"
- "밈스럽게 / MZ스럽게 / 재치있게 써줘"
- "이 유행어가 무슨 뜻이야?" (시드 인덱스 + 나무위키 lookup)
- "긍정적인 느낌의 유행어 3개 추천해줘"
- "이 상품 카피에 어울리는 유행어 후보 뽑아줘"
## When NOT to use
- 격식 공문, 보도자료, 법률 문서, 사과문, 공식 발표문 등 유행어 사용이 부적절한 문맥
- 민감한 사회·정치·종교 주제에 풍자 유행어를 끼얹어야 하는 상황
- 대량 인덱싱 / 전체 웹 스크래핑 (나무위키 약관·Cloudflare 정책 위반 소지)
- 불특정 다수 대상의 브랜딩 문구에서 유행어 과다 사용 (저자가 확인·동의하지 않은 경우)
## Policy first
- 유행어는 **자연스럽게 적게** 쓰는 것을 기본값으로 한다. 과다 사용은 오히려 촌스럽다.
- 뜻과 용례를 확인하지 못한 유행어는 사용하지 않는다.
- `safety``spicy` 인 항목은 비격식 컨텍스트에서만 쓰고, 격식 자리에는 피한다.
- `still_usable: false` 표시가 붙은 구형 유행어는 기본 검색 결과에 포함되지 않는다 (`--include-deprecated` 로 의도적 복원 가능).
- 나무위키는 best-effort 출처이며 Cloudflare 차단·HTML 구조 변경 시 `fetched: false` 또는 `warning` 으로 응답한다. 이 경우 추측하지 말고 시드 인덱스 meaning_short + 링크만 안내한다.
## Prerequisites
- `python3` 3.10+ (`argparse`, `urllib`, `unittest` 만 사용 — 추가 의존성 없음)
- 스킬 디렉터리의 `scripts/*.py` helper (설치 시 자동 포함)
- 나무위키 lookup 에는 인터넷 연결이 필요하지만, 실패해도 시드 인덱스만으로 안전하게 응답할 수 있다.
## Response contract
`slang_search` 결과 스키마 (v1):
```json
{
"query": "중꺾마",
"filters_applied": {
"mood": ["긍정"],
"context": ["SNS"],
"safety": ["safe"],
"intensity": [],
"limit": 10,
"include_deprecated": false
},
"matched_before_limit": 12,
"total_candidates": 5,
"candidates": [
{
"term": "중꺾마",
"aliases": ["중요한 건 꺾이지 않는 마음"],
"meaning_short": "포기하지 않는 불굴의 의지.",
"usage_context": ["격려", "스포츠", "SNS"],
"mood_tags": ["긍정", "의지"],
"intensity": "medium",
"safety": "safe",
"example_usage": ["힘들지만 중꺾마 정신으로 이겨내자!"],
"namuwiki_url": "https://namu.wiki/w/...",
"era": "2022",
"still_usable": true,
"match_reason": "exact"
}
],
"source": "...",
"last_reviewed": "2026-04-22"
}
```
`match_reason` 우선순위: `exact` > `alias` > `substring` > `no-query` (필터만 적용한 경우).
`slang_lookup` 결과 스키마:
```json
{
"input": "중꺾마",
"url": "https://namu.wiki/w/%EC%A4%91%EA%BA%BE%EB%A7%88",
"fetched": true,
"title": "중꺾마",
"summary": "중꺾마는 ...",
"error": null,
"block_reason": null,
"warning": "optional, when HTML parsing failed"
}
```
`fetched: false` 인 경우 `block_reason``blocked` / `not_found` / `upstream_error` 중 하나이며, 에이전트는 추측하지 않고 원문 링크를 제시하거나 다른 출처 확인을 제안한다.
## Workflow
### 1. 사용자 의도 정리
- 글쓰기 목적 (홍보, 댓글, 자기소개, 제목 등)
- 유행어 강도 (살짝 / 적당히 / 많이)
- 톤/무드 (긍정, 부정, 유머, 자조, 감탄 등)
- 문맥 (SNS, 마케팅, 음식, 스포츠, 직장, 학교 등)
### 2. 시드 인덱스에서 후보 탐색
```bash
python3 scripts/slang_search.py --mood "긍정,유머" --context "SNS,마케팅" --safety safe --limit 5 --format json
```
주요 옵션:
| 옵션 | 설명 | 예 |
|------|------|-----|
| `--query` | 키워드 매칭 (term/aliases/substring) | `--query 중꺾마` |
| `--mood` | 무드 태그 (OR 매칭) | `--mood 긍정,유머` |
| `--context` | 문맥 태그 (OR 매칭) | `--context SNS,마케팅` |
| `--safety` | 안전 수준 (safe/spicy/risky) | `--safety safe,spicy` |
| `--intensity` | 강도 (subtle/medium/strong) | `--intensity medium,strong` |
| `--limit` | 반환 개수 (1~50) | `--limit 5` |
| `--include-deprecated` | 구형 유행어 포함 | `--include-deprecated` |
| `--index-path` | 외부 인덱스 JSON 경로 | `--index-path ./my-index.json` |
| `--format` | `json` 또는 `text` | `--format text` |
### 3. (선택) 나무위키 원문 요약 확인
```bash
python3 scripts/slang_lookup.py "중꺾마" --format json
python3 scripts/slang_lookup.py "https://namu.wiki/w/%EC%A4%91%EA%BA%BE%EB%A7%88" --max-length 800
```
- 성공 시 `title`, `summary` 를 돌려준다.
- 실패 시 `fetched: false` + `block_reason` + `error` 를 돌려준다. 추측하지 말 것.
### 4. 에이전트가 글 작성
1. 후보 중 문맥에 맞는 1~3개를 선택한다. 많이 넣지 않는다.
2. `safety``spicy` 인 표현은 비격식 컨텍스트에서만 쓰고, 사용 의도를 분명히 한다.
3. 결과물을 1~2문장으로 자연스럽게 녹인다.
4. 필요하면 "사용한 표현: A, B — 의미 확인: [url]" 을 덧붙여 근거 링크를 보여준다.
5. 사용자가 더 과한/덜 과한 버전을 원하면 대안을 함께 제공한다.
## CLI examples
```bash
# 1) 특정 유행어 뜻/메타 바로 확인
python3 scripts/slang_search.py --query "갓생" --format text
# 2) 긍정 무드의 SNS 마케팅 후보 5개
python3 scripts/slang_search.py --mood "긍정,유머" --context "SNS,마케팅" --safety safe --limit 5
# 3) 이용자가 지정한 커스텀 인덱스로 전환
python3 scripts/slang_search.py --query "중꺾마" --index-path ~/Downloads/my-slang.json
# 4) 나무위키 원문 요약 시도
python3 scripts/slang_lookup.py "럭키비키" --timeout 10 --format text
```
## Response policy
- 응답에는 **선택한 유행어, 최종 문장, 근거 링크** 세 가지를 포함한다.
- 유행어를 2개 이상 썼다면 각각 왜 썼는지 한 줄 이유를 붙인다.
- 사용자가 명시적으로 요청하지 않은 상황에서는 유행어를 끼얹지 않는다.
- 나무위키 요약이 없을 때는 시드의 `meaning_short` 와 원문 링크로 대체한다. "잘 모르겠다" 를 숨기지 않는다.
## Done when
- `slang_search` 가 JSON 으로 최소 한 개의 후보를 돌려주었거나, 의도적으로 0건 (no match) 을 명확히 전달했다.
- 유행어를 사용한 경우, 각 표현의 뜻/근거 링크가 함께 제시되었다.
- `safety=risky` 표현은 시드에 없고, `spicy` 표현을 썼다면 왜 괜찮은 문맥인지 한 줄 설명이 있다.
- 나무위키 lookup 실패 시 추측 대신 원문 링크로 대체되었다.
## Notes
- 시드 인덱스 위치: `data/seed-slang.json` (약 30개 항목). 확장은 PR 로 검토 후 반영한다.
- `last_reviewed` 필드를 의미 있게 업데이트하지 않는 변경은 받지 않는 편이 안전하다.
- 이 스킬은 **나무위키 분류:유행어** 를 기반으로 하지만 전체 인덱싱하지 않는다. Cloudflare 차단·데이터 선도 변화에 보수적으로 설계된 v1 이다.
- 다음 버전에서는 여러 공개 소스를 통합하거나 `last_reviewed` 기반 자동 만료 로직을 넣을 수 있다.

View file

@ -0,0 +1,472 @@
{
"schema_version": "1.0",
"source": "https://namu.wiki/w/%EB%B6%84%EB%A5%98:%EC%9C%A0%ED%96%89%EC%96%B4 (분류:유행어) + 큐레이션",
"last_reviewed": "2026-04-22",
"notes": "v1 시드 인덱스. 에이전트는 필요 시 slang_lookup.py 로 namuwiki 원문 요약을 확인한다. safety 수준이 risky 인 항목은 포함하지 않는다.",
"entries": [
{
"term": "중꺾마",
"aliases": ["중요한 건 꺾이지 않는 마음", "중꺾그마"],
"meaning_short": "어려운 상황에서도 포기하지 않는 불굴의 의지.",
"usage_context": ["격려", "스포츠", "동기부여", "SNS"],
"mood_tags": ["긍정", "의지", "감동"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"힘들지만 중꺾마 정신으로 끝까지 간다!",
"중꺾마, 올해도 잘 버텨보자."
],
"namuwiki_url": "https://namu.wiki/w/%EC%A4%91%EA%BA%BE%EB%A7%88",
"era": "2022",
"still_usable": true
},
{
"term": "갓생",
"aliases": ["갓생 살기", "갓생러"],
"meaning_short": "신(God)처럼 부지런하고 생산적인 삶.",
"usage_context": ["동기부여", "일상", "자기계발", "SNS"],
"mood_tags": ["긍정", "의지", "성실"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"오늘부터 갓생 산다.",
"갓생러들의 아침 루틴 모음."
],
"namuwiki_url": "https://namu.wiki/w/%EA%B0%93%EC%83%9D",
"era": "2021",
"still_usable": true
},
{
"term": "오운완",
"aliases": ["오늘 운동 완료"],
"meaning_short": "오늘의 운동을 마쳤다는 성취 인증.",
"usage_context": ["일상", "운동", "헬스", "SNS"],
"mood_tags": ["긍정", "성취", "뿌듯함"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"오운완! 오늘도 해냈다.",
"#오운완 #헬창의일상"
],
"namuwiki_url": "https://namu.wiki/w/%EC%98%A4%EC%9A%B4%EC%99%84",
"era": "2020",
"still_usable": true
},
{
"term": "꾸안꾸",
"aliases": ["꾸민 듯 안 꾸민 듯"],
"meaning_short": "꾸민 티 안 나게 자연스럽게 꾸민 스타일.",
"usage_context": ["패션", "뷰티", "일상"],
"mood_tags": ["중성", "세련"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"꾸안꾸 데일리룩 추천해드려요.",
"꾸안꾸가 제일 어렵다."
],
"namuwiki_url": "https://namu.wiki/w/%EA%BE%B8%EC%95%88%EA%BE%B8",
"era": "2019",
"still_usable": true
},
{
"term": "TMI",
"aliases": ["티엠아이", "Too Much Information"],
"meaning_short": "굳이 안 궁금한 너무 많은 정보.",
"usage_context": ["대화", "SNS", "유머"],
"mood_tags": ["중성", "유머"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"TMI지만 저 오늘 양말 짝짝이로 신었어요.",
"TMI 주의, 스크롤 내리세요."
],
"namuwiki_url": "https://namu.wiki/w/TMI",
"era": "2018",
"still_usable": true
},
{
"term": "현타",
"aliases": ["현실 자각 타임", "현자타임"],
"meaning_short": "몰두하던 일 끝에 몰려오는 공허함이나 현실 자각.",
"usage_context": ["일상", "감정표현", "유머"],
"mood_tags": ["부정", "허무", "자조"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"카드값 보고 갑자기 현타 왔다.",
"몰아보기 끝나고 오는 그 현타..."
],
"namuwiki_url": "https://namu.wiki/w/%ED%98%84%ED%83%80",
"era": "2015",
"still_usable": true
},
{
"term": "갑분싸",
"aliases": ["갑자기 분위기 싸해짐"],
"meaning_short": "한순간에 분위기가 어색하거나 썰렁해진 상황.",
"usage_context": ["유머", "대화", "일상"],
"mood_tags": ["부정", "어색", "유머"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"아재 개그 한 번에 갑분싸 됐어.",
"갑분싸 만들기 장인"
],
"namuwiki_url": "https://namu.wiki/w/%EA%B0%91%EB%B6%84%EC%8B%B8",
"era": "2018",
"still_usable": true
},
{
"term": "워라밸",
"aliases": ["Work-Life Balance", "일과 삶의 균형"],
"meaning_short": "일과 개인 삶 사이의 균형.",
"usage_context": ["직장", "일상", "자기계발"],
"mood_tags": ["중성", "가치관"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"이 회사는 워라밸이 확실히 좋다.",
"워라밸 지키면서 일하기."
],
"namuwiki_url": "https://namu.wiki/w/%EC%9B%8C%EB%9D%BC%EB%B0%B8",
"era": "2017",
"still_usable": true
},
{
"term": "존버",
"aliases": ["존나게 버티기"],
"meaning_short": "힘들어도 끝까지 버티는 행동. 비속어 어근이 섞여 있어 격식 상황에서는 주의.",
"usage_context": ["투자", "업무", "의지"],
"mood_tags": ["중성", "의지", "인내"],
"intensity": "strong",
"safety": "spicy",
"example_usage": [
"주가 떨어져도 일단 존버 중.",
"존버는 승리한다."
],
"namuwiki_url": "https://namu.wiki/w/%EC%A1%B4%EB%B2%84",
"era": "2018",
"still_usable": true
},
{
"term": "만반잘부",
"aliases": ["만나서 반가워 잘 부탁해"],
"meaning_short": "새로 만난 사이 캐주얼 인사말.",
"usage_context": ["인사", "자기소개", "SNS"],
"mood_tags": ["긍정", "친근"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"저 오늘 새로 왔습니다. 만반잘부!",
"만반잘부 💕"
],
"namuwiki_url": "https://namu.wiki/w/%EB%A7%8C%EB%B0%98%EC%9E%98%EB%B6%80",
"era": "2020",
"still_usable": true
},
{
"term": "억텐",
"aliases": ["억지 텐션"],
"meaning_short": "무리해서 끌어올린 과장된 텐션.",
"usage_context": ["유머", "자조", "SNS"],
"mood_tags": ["유머", "자조"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"오늘 피곤한데 억텐으로 버틴다.",
"억텐 아니고 찐텐이야."
],
"namuwiki_url": "https://namu.wiki/w/%EC%96%B5%ED%85%90",
"era": "2021",
"still_usable": true
},
{
"term": "찐텐",
"aliases": ["진짜 텐션"],
"meaning_short": "꾸밈없이 진심으로 올라온 텐션.",
"usage_context": ["유머", "SNS", "일상"],
"mood_tags": ["긍정", "진심"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"이거 찐텐이야, 진짜 웃겨.",
"찐텐 터지는 순간"
],
"namuwiki_url": "https://namu.wiki/w/%EC%B0%90%ED%85%90",
"era": "2021",
"still_usable": true
},
{
"term": "손절",
"aliases": ["손절하다"],
"meaning_short": "인간관계를 단호하게 끊는 행위. 주식 용어에서 전이됨.",
"usage_context": ["관계", "일상", "투자"],
"mood_tags": ["부정", "단호"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"그런 친구는 그냥 손절해.",
"이 주식 손절할까 고민 중."
],
"namuwiki_url": "https://namu.wiki/w/%EC%86%90%EC%A0%88",
"era": "2018",
"still_usable": true
},
{
"term": "입덕",
"aliases": ["입덕하다"],
"meaning_short": "어떤 대상/스타의 팬이 되어 빠지기 시작하는 것.",
"usage_context": ["취미", "덕질", "SNS"],
"mood_tags": ["긍정", "애정"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"이 아이돌 무대 보고 바로 입덕했어.",
"입덕 축하해요!"
],
"namuwiki_url": "https://namu.wiki/w/%EC%9E%85%EB%8D%95",
"era": "2015",
"still_usable": true
},
{
"term": "덕질",
"aliases": ["덕후질"],
"meaning_short": "좋아하는 대상을 열정적으로 파고드는 활동.",
"usage_context": ["취미", "SNS", "일상"],
"mood_tags": ["긍정", "애정", "열정"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"오늘도 덕질 중입니다.",
"덕질은 돈이 참 많이 드는 취미야."
],
"namuwiki_url": "https://namu.wiki/w/%EB%8D%95%EC%A7%88",
"era": "2015",
"still_usable": true
},
{
"term": "아아",
"aliases": ["아이스 아메리카노"],
"meaning_short": "아이스 아메리카노. 한국인 카페 주문 공식 음료.",
"usage_context": ["일상", "카페", "음료"],
"mood_tags": ["중성", "일상"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"무조건 아아 한 잔.",
"한겨울에도 아아 시키는 한국인."
],
"namuwiki_url": "https://namu.wiki/w/%EC%95%84%EC%9D%B4%EC%8A%A4%20%EC%95%84%EB%A9%94%EB%A6%AC%EC%B9%B4%EB%85%B8",
"era": "2015",
"still_usable": true
},
{
"term": "존맛탱",
"aliases": ["JMT", "존맛"],
"meaning_short": "엄청나게 맛있다는 강조 표현. 비속어 어근 포함으로 격식 상황 주의.",
"usage_context": ["음식", "SNS", "리뷰"],
"mood_tags": ["긍정", "감탄", "맛"],
"intensity": "strong",
"safety": "spicy",
"example_usage": [
"여기 김치찌개 JMT!",
"존맛탱! 다시 올 거야."
],
"namuwiki_url": "https://namu.wiki/w/%EC%A1%B4%EB%A7%9B%ED%83%B1",
"era": "2015",
"still_usable": true
},
{
"term": "노잼",
"aliases": ["노잼이다"],
"meaning_short": "재미없다는 뜻의 부정 평가.",
"usage_context": ["평가", "SNS", "유머"],
"mood_tags": ["부정", "평가"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"그 영화 노잼이었어.",
"오늘 하루 진짜 노잼."
],
"namuwiki_url": "https://namu.wiki/w/%EB%85%B8%EC%9E%BC",
"era": "2015",
"still_usable": true
},
{
"term": "꿀잼",
"aliases": ["꿀잼이다"],
"meaning_short": "매우 재미있다는 긍정 평가.",
"usage_context": ["평가", "SNS", "유머"],
"mood_tags": ["긍정", "유머"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"이번 주 예능 꿀잼!",
"이거 생각보다 꿀잼이네."
],
"namuwiki_url": "https://namu.wiki/w/%EA%BF%80%EC%9E%BC",
"era": "2015",
"still_usable": true
},
{
"term": "스불재",
"aliases": ["스스로 불러온 재앙"],
"meaning_short": "자기 탓에 일어난 불행을 유머러스하게 표현한 말.",
"usage_context": ["유머", "자조", "SNS"],
"mood_tags": ["유머", "자조", "부정"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"시험기간에 드라마 몰아보기… 이거 스불재다.",
"스불재지만 어쩌겠어."
],
"namuwiki_url": "https://namu.wiki/w/%EC%8A%A4%EB%B6%88%EC%9E%AC",
"era": "2019",
"still_usable": true
},
{
"term": "마상",
"aliases": ["마음의 상처"],
"meaning_short": "사소한 한마디에 받은 속상한 감정.",
"usage_context": ["유머", "감정표현", "일상"],
"mood_tags": ["부정", "유머", "소심"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"그 말 듣고 마상 왔어.",
"너 그러면 나 마상 입어."
],
"namuwiki_url": "https://namu.wiki/w/%EB%A7%88%EC%83%81",
"era": "2020",
"still_usable": true
},
{
"term": "럭키비키",
"aliases": ["Lucky Vicky"],
"meaning_short": "어떤 일도 긍정적으로 '운 좋았다'로 해석하는 초긍정 태도. 장원영의 인터뷰 밈에서 유래.",
"usage_context": ["긍정", "일상", "SNS", "마케팅"],
"mood_tags": ["긍정", "낙관", "유머"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"버스 방금 놓쳤는데 다음 거 앉아서 왔네, 럭키비키잖아!",
"이것도 럭키비키야."
],
"namuwiki_url": "https://namu.wiki/w/%EB%9F%AD%ED%82%A4%EB%B9%84%ED%82%A4",
"era": "2024",
"still_usable": true
},
{
"term": "꼰대",
"aliases": [],
"meaning_short": "권위적인 태도로 훈계하거나 가르치려 드는 사람에 대한 비하 표현.",
"usage_context": ["세대", "직장", "관계"],
"mood_tags": ["부정", "풍자"],
"intensity": "medium",
"safety": "spicy",
"example_usage": [
"꼰대 같은 말은 하지 말자.",
"스스로 꼰대가 되지 않기 위해 노력 중."
],
"namuwiki_url": "https://namu.wiki/w/%EA%BC%B0%EB%8C%80",
"era": "2015",
"still_usable": true
},
{
"term": "현생",
"aliases": ["현실 생활"],
"meaning_short": "덕질/게임/몰두하는 취미 밖의 일상적 현실 생활.",
"usage_context": ["일상", "덕질", "SNS"],
"mood_tags": ["중성", "일상"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"현생 바빠서 오늘은 덕질 못 해.",
"현생 리셋 시간."
],
"namuwiki_url": "https://namu.wiki/w/%ED%98%84%EC%83%9D",
"era": "2018",
"still_usable": true
},
{
"term": "킹받다",
"aliases": ["킹받네"],
"meaning_short": "'열받다'를 강조한 유행 표현. 짜증이나 약오름을 유머러스하게 나타냄.",
"usage_context": ["유머", "감정표현", "SNS"],
"mood_tags": ["부정", "유머", "짜증"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"이거 왜 안 되는 거야, 킹받네.",
"진짜 킹받는다 오늘."
],
"namuwiki_url": "https://namu.wiki/w/%ED%82%B9%EB%B0%9B%EB%8B%A4",
"era": "2021",
"still_usable": true
},
{
"term": "어쩔티비",
"aliases": ["어쩔TV", "저쩔티비"],
"meaning_short": "상대의 말에 '어쩌라고'로 응수하는 초딩풍 유행어. 과하면 유치해 보일 수 있음.",
"usage_context": ["유머", "반박"],
"mood_tags": ["유머", "반박"],
"intensity": "strong",
"safety": "spicy",
"example_usage": [
"그래서 어쩔티비.",
"어쩔티비 저쩔티비."
],
"namuwiki_url": "https://namu.wiki/w/%EC%96%B4%EC%A9%94%ED%8B%B0%EB%B9%84",
"era": "2021",
"still_usable": true
},
{
"term": "혼밥",
"aliases": ["혼자 밥 먹기"],
"meaning_short": "혼자 식사하는 것. 이제는 널리 정착된 생활방식 표현.",
"usage_context": ["일상", "음식", "라이프스타일"],
"mood_tags": ["중성", "일상"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"오늘은 혼밥 해야지.",
"혼밥 하기 좋은 가게 추천해줘."
],
"namuwiki_url": "https://namu.wiki/w/%ED%98%BC%EB%B0%A5",
"era": "2015",
"still_usable": true
},
{
"term": "인싸",
"aliases": ["인사이더"],
"meaning_short": "모임·무리 안에서 잘 어울려 주도하는 사람. 반대말은 아싸.",
"usage_context": ["사회", "학교", "직장"],
"mood_tags": ["중성"],
"intensity": "subtle",
"safety": "safe",
"example_usage": [
"저 친구는 진짜 인싸야.",
"인싸템 하나 사볼까."
],
"namuwiki_url": "https://namu.wiki/w/%EC%9D%B8%EC%8B%B8",
"era": "2017",
"still_usable": true
},
{
"term": "역대급",
"aliases": [],
"meaning_short": "역사상 최고 수준이라는 감탄. 긍·부정 모두에 쓸 수 있음.",
"usage_context": ["감탄", "리뷰", "SNS"],
"mood_tags": ["감탄", "강조"],
"intensity": "medium",
"safety": "safe",
"example_usage": [
"이번 공연 역대급이었다.",
"역대급 폭염."
],
"namuwiki_url": "https://namu.wiki/w/%EC%97%AD%EB%8C%80%EA%B8%89",
"era": "2015",
"still_usable": true
}
]
}

View file

@ -0,0 +1,91 @@
from __future__ import annotations
import ssl
import urllib.error
import urllib.parse
import urllib.request
NAMUWIKI_BASE = "https://namu.wiki/w/"
_NAMUWIKI_HOSTS = ("namu.wiki", "en.namu.wiki")
_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"
),
}
_ssl_ctx: ssl.SSLContext | None = None
class LookupError(Exception):
pass
class BlockedError(LookupError):
pass
class NotFoundError(LookupError):
pass
class UpstreamError(LookupError):
pass
def _get_ssl_context() -> ssl.SSLContext:
global _ssl_ctx
if _ssl_ctx is None:
_ssl_ctx = ssl.create_default_context()
return _ssl_ctx
def is_namuwiki_url(url: str) -> bool:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"}:
return False
host = (parsed.hostname or "").lower()
return host in _NAMUWIKI_HOSTS
def build_namuwiki_url(term_or_url: str) -> str:
value = term_or_url.strip()
if not value:
raise ValueError("term is empty")
if is_namuwiki_url(value):
return value
# Preserve slash so namuwiki subpage titles (e.g. "한국/서울") survive encoding.
quoted = urllib.parse.quote(value, safe="/")
return f"{NAMUWIKI_BASE}{quoted}"
def browser_headers() -> dict[str, str]:
return dict(_BROWSER_HEADERS)
def fetch_html(url: str, timeout: int) -> str:
if not is_namuwiki_url(url):
raise ValueError(f"not a namuwiki URL: {url}")
request = urllib.request.Request(url, headers=browser_headers())
try:
with urllib.request.urlopen(request, timeout=timeout, context=_get_ssl_context()) as response:
body = response.read()
except urllib.error.HTTPError as error:
status = error.code
if status == 404:
raise NotFoundError(f"HTTP 404: {url}") from error
if status in (401, 403, 429):
raise BlockedError(
f"HTTP {status} (possibly Cloudflare / rate-limited) for {url}"
) from error
raise UpstreamError(f"HTTP {status} for {url}") from error
except urllib.error.URLError as error:
raise UpstreamError(f"URL error for {url}: {error.reason}") from error
except TimeoutError as error:
raise UpstreamError(f"timeout after {timeout}s for {url}") from error
return body.decode("utf-8", "ignore")

View file

@ -0,0 +1,291 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from html import unescape
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _slang_http import ( # noqa: E402
BlockedError,
NotFoundError,
UpstreamError,
build_namuwiki_url,
fetch_html,
)
DEFAULT_TIMEOUT = 15
DEFAULT_MAX_LENGTH = 1500
TAG_RE = re.compile(r"<[^>]+>")
SCRIPT_STYLE_RE = re.compile(
r"<(script|style|noscript)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE
)
TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.DOTALL | re.IGNORECASE)
NAMUWIKI_TITLE_SUFFIX_RE = re.compile(r"\s*[-|]?\s*나무위키\s*$")
BLOCK_END_RE = re.compile(r"</(p|div|li|h[1-6])>", re.IGNORECASE)
BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
WHITESPACE_RE = re.compile(r"[ \t]+")
BLANK_LINES_RE = re.compile(r"\n{3,}")
H2_TAG_RE = re.compile(r"<h2\b[^>]*>.*?</h2>", re.DOTALL | re.IGNORECASE)
NUMBERED_H2_INNER_TEXT_RE = re.compile(r"^\s*\d+(?:\.\d+)*\.\s+\S")
SECTION_NUMBER_PREFIX_RE = re.compile(r"^\s*\d+(?:\.\d+)*\.\s+", re.MULTILINE)
EDIT_AFFORDANCE_RE = re.compile(r"\[\s*편집\s*\]")
CATEGORY_NAV_RE = re.compile(r"\[\s*펼치기\s*[·・•]\s*접기\s*\][^\n]*")
DETAILS_PELCHIGI_RE = re.compile(
r"<details\b[^>]*>"
r"\s*<summary\b[^>]*>[^<]*펼치기[^<]*</summary>"
r".*?"
r"</details>",
re.DOTALL | re.IGNORECASE,
)
OG_DESCRIPTION_RE = re.compile(
r'<meta\s+[^>]*property\s*=\s*"og:description"\s+[^>]*content\s*=\s*"([^"]*)"',
re.IGNORECASE,
)
OG_DESCRIPTION_REVERSED_RE = re.compile(
r'<meta\s+[^>]*content\s*=\s*"([^"]*)"\s+[^>]*property\s*=\s*"og:description"',
re.IGNORECASE,
)
MAIN_CONTENT_CLASSES = (
"wiki-paragraph",
"wiki-content",
"namu-wiki-content",
"article-content",
"wiki-body",
"wiki-heading-content",
)
def fetch_page(url: str, timeout: int) -> str:
return fetch_html(url, timeout=timeout)
def extract_title(html: str) -> str:
match = TITLE_RE.search(html)
if not match:
return ""
title = unescape(TAG_RE.sub("", match.group(1))).strip()
title = NAMUWIKI_TITLE_SUFFIX_RE.sub("", title).strip()
return title
def _find_main_content(cleaned_html: str) -> str:
for class_name in MAIN_CONTENT_CLASSES:
pattern = re.compile(
rf'<[a-zA-Z]+[^>]*class="[^"]*\b{re.escape(class_name)}\b[^"]*"[^>]*>',
re.IGNORECASE,
)
match = pattern.search(cleaned_html)
if match:
return cleaned_html[match.start():]
article_match = re.search(r"<article[^>]*>", cleaned_html, re.IGNORECASE)
if article_match:
return cleaned_html[article_match.start():]
return ""
def _h2_inner_text(h2_tag_html: str) -> str:
opening_end = h2_tag_html.index(">") + 1
closing_start = h2_tag_html.rindex("<")
inner = h2_tag_html[opening_end:closing_start]
return unescape(TAG_RE.sub("", inner)).strip()
def _is_numbered_section_h2(h2_tag_html: str) -> bool:
return bool(NUMBERED_H2_INNER_TEXT_RE.match(_h2_inner_text(h2_tag_html)))
def _extract_first_section_between_h2(cleaned_html: str) -> str:
all_matches = list(H2_TAG_RE.finditer(cleaned_html))
numbered = [m for m in all_matches if _is_numbered_section_h2(m.group(0))]
if not numbered:
return ""
start = numbered[0].end()
end = numbered[1].start() if len(numbered) > 1 else len(cleaned_html)
return cleaned_html[start:end]
def _extract_og_description(html: str) -> str:
match = OG_DESCRIPTION_RE.search(html) or OG_DESCRIPTION_REVERSED_RE.search(html)
if not match:
return ""
return unescape(match.group(1)).strip()
def _html_fragment_to_text(fragment: str) -> str:
text = BR_RE.sub("\n", fragment)
text = BLOCK_END_RE.sub("\n", text)
text = TAG_RE.sub("", text)
text = unescape(text)
text = EDIT_AFFORDANCE_RE.sub("", text)
text = CATEGORY_NAV_RE.sub("", text)
text = SECTION_NUMBER_PREFIX_RE.sub("", text)
lines: list[str] = []
for line in text.split("\n"):
stripped = WHITESPACE_RE.sub(" ", line).strip()
if stripped:
lines.append(stripped)
joined = "\n".join(lines)
return BLANK_LINES_RE.sub("\n\n", joined).strip()
def _truncate(text: str, max_length: int) -> str:
if max_length > 0 and len(text) > max_length:
return text[:max_length] + "..."
return text
def extract_summary(html: str, *, max_length: int = DEFAULT_MAX_LENGTH) -> str:
cleaned = SCRIPT_STYLE_RE.sub("", html)
cleaned = DETAILS_PELCHIGI_RE.sub("", cleaned)
h2_section = _extract_first_section_between_h2(cleaned)
if h2_section:
text = _html_fragment_to_text(h2_section)
if text:
return _truncate(text, max_length)
region = _find_main_content(cleaned)
if region:
text = _html_fragment_to_text(region)
if text:
return _truncate(text, max_length)
og_description = _extract_og_description(html)
if og_description:
return _truncate(og_description, max_length)
return ""
def _is_url(value: str) -> bool:
return value.startswith("http://") or value.startswith("https://")
def lookup(
term_or_url: str,
*,
timeout: int = DEFAULT_TIMEOUT,
max_length: int = DEFAULT_MAX_LENGTH,
) -> dict[str, Any]:
input_value = term_or_url.strip()
if not input_value:
raise ValueError("term_or_url is empty")
url = build_namuwiki_url(input_value)
result: dict[str, Any] = {
"input": term_or_url,
"url": url,
"fetched": False,
"title": "",
"summary": "",
"error": None,
"block_reason": None,
}
try:
html = fetch_page(url, timeout=timeout)
except BlockedError as error:
result["error"] = str(error)
result["block_reason"] = "blocked"
return result
except NotFoundError as error:
result["error"] = str(error)
result["block_reason"] = "not_found"
return result
except UpstreamError as error:
result["error"] = str(error)
result["block_reason"] = "upstream_error"
return result
result["fetched"] = True
result["title"] = extract_title(html)
result["summary"] = extract_summary(html, max_length=max_length)
if not result["summary"]:
result["warning"] = (
"Main content region not detected. Namu Wiki HTML layout may have changed; "
"treat this as a hint and verify meaning from seed index or other sources."
)
return result
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Fetch a Namu Wiki page for a trending slang term and return a best-effort "
"summary. Gracefully reports when the upstream blocks the request."
)
)
parser.add_argument(
"term_or_url",
help="Slang term (e.g. '중꺾마') or full Namu Wiki URL.",
)
parser.add_argument(
"--timeout",
type=int,
default=DEFAULT_TIMEOUT,
help=f"HTTP timeout in seconds. Default: {DEFAULT_TIMEOUT}.",
)
parser.add_argument(
"--max-length",
type=int,
default=DEFAULT_MAX_LENGTH,
help=f"Summary truncation length (0 = unlimited). Default: {DEFAULT_MAX_LENGTH}.",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
help="Output format.",
)
return parser.parse_args(argv)
def _format_text(result: dict) -> str:
lines: list[str] = []
lines.append(f"URL: {result['url']}")
if result["fetched"]:
lines.append(f"Title: {result['title']}")
lines.append("")
lines.append(result["summary"] or "(summary not extracted)")
else:
lines.append("Fetch failed.")
lines.append(f"Reason: {result.get('block_reason')}")
lines.append(f"Detail: {result.get('error')}")
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv if argv is not None else sys.argv[1:])
try:
result = lookup(
args.term_or_url,
timeout=args.timeout,
max_length=args.max_length,
)
except ValueError as error:
print(
json.dumps({"error": str(error)}, ensure_ascii=False),
file=sys.stderr,
)
return 1
if args.format == "json":
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
sys.stdout.write(_format_text(result))
return 0 if result["fetched"] else 2
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,284 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
from typing import Any, Iterable
DEFAULT_LIMIT = 10
MAX_LIMIT = 50
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
SKILL_ROOT = SCRIPT_DIR.parent
DEFAULT_INDEX_PATH = SKILL_ROOT / "data" / "seed-slang.json"
MATCH_REASON_ORDER = {
"exact": 0,
"alias": 1,
"substring": 2,
"no-query": 3,
}
def load_index(path: str | None = None) -> dict:
target = pathlib.Path(path) if path else DEFAULT_INDEX_PATH
if not target.exists():
raise FileNotFoundError(f"slang index not found at: {target}")
with target.open(encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict) or "entries" not in data:
raise ValueError(f"invalid slang index (missing 'entries'): {target}")
return data
def _normalize(text: str) -> str:
return " ".join(text.lower().split())
def _collect_match(entry: dict, query_norm: str) -> str | None:
term_norm = _normalize(entry.get("term", ""))
if not query_norm:
return "no-query"
if term_norm == query_norm:
return "exact"
aliases = entry.get("aliases") or []
alias_norms = [_normalize(a) for a in aliases]
if query_norm in alias_norms:
return "alias"
if query_norm in term_norm:
return "substring"
for alias_norm in alias_norms:
if query_norm and query_norm in alias_norm:
return "substring"
return None
def _ensure_list(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, str):
return [value]
if isinstance(value, Iterable):
return [str(v) for v in value]
return [str(value)]
def _has_overlap(entry_tags: list[str], requested: list[str]) -> bool:
if not requested:
return True
entry_set = {t.strip().lower() for t in entry_tags}
requested_set = {t.strip().lower() for t in requested}
return bool(entry_set & requested_set)
def _matches_single(value: str | None, allowed: list[str]) -> bool:
if not allowed:
return True
if value is None:
return False
return value.strip().lower() in {a.strip().lower() for a in allowed}
def _era_sort_key(era: str) -> int:
digits = "".join(ch for ch in era if ch.isdigit())
try:
return -int(digits[:4]) if digits else 0
except ValueError:
return 0
def search(
*,
query: str | None = None,
mood: list[str] | None = None,
context: list[str] | None = None,
safety: str | list[str] | None = None,
intensity: str | list[str] | None = None,
limit: int = DEFAULT_LIMIT,
include_deprecated: bool = False,
index: dict | None = None,
index_path: str | None = None,
) -> dict:
if index is None:
index = load_index(index_path)
entries: list[dict] = list(index.get("entries", []))
mood_list = _ensure_list(mood)
context_list = _ensure_list(context)
safety_list = _ensure_list(safety)
intensity_list = _ensure_list(intensity)
query_norm = _normalize(query) if query else ""
clamped_limit = max(1, min(int(limit), MAX_LIMIT))
scored: list[tuple[int, int, str, dict]] = []
for entry in entries:
if not include_deprecated and not entry.get("still_usable", True):
continue
match_reason = _collect_match(entry, query_norm)
if match_reason is None:
continue
if not _has_overlap(entry.get("mood_tags") or [], mood_list):
continue
if not _has_overlap(entry.get("usage_context") or [], context_list):
continue
if not _matches_single(entry.get("safety"), safety_list):
continue
if not _matches_single(entry.get("intensity"), intensity_list):
continue
order = MATCH_REASON_ORDER.get(match_reason, 9)
era_rank = _era_sort_key(str(entry.get("era", "")))
scored.append((order, era_rank, str(entry.get("term", "")), {**entry, "match_reason": match_reason}))
scored.sort(key=lambda item: (item[0], item[1], item[2]))
matched_before_limit = len(scored)
candidates = [row[3] for row in scored[:clamped_limit]]
return {
"query": query,
"filters_applied": {
"mood": mood_list,
"context": context_list,
"safety": safety_list,
"intensity": intensity_list,
"limit": clamped_limit,
"include_deprecated": include_deprecated,
},
"matched_before_limit": matched_before_limit,
"total_candidates": len(candidates),
"candidates": candidates,
"source": index.get("source", ""),
"last_reviewed": index.get("last_reviewed", ""),
}
def _format_text(result: dict) -> str:
if not result["candidates"]:
return "No candidates found.\n"
lines: list[str] = []
query = result.get("query") or "(no query)"
lines.append(f"Query: {query}")
lines.append(
f"Matched: {result['matched_before_limit']} -> showing {result['total_candidates']}"
)
lines.append("")
for idx, entry in enumerate(result["candidates"], start=1):
mood = ", ".join(entry.get("mood_tags") or []) or "-"
context = ", ".join(entry.get("usage_context") or []) or "-"
lines.append(
f"{idx}. {entry['term']} ({entry.get('era', '?')}) "
f"[{entry.get('safety', '?')}, {entry.get('intensity', '?')}]"
)
lines.append(f" mood: {mood}")
lines.append(f" context: {context}")
lines.append(f" meaning: {entry.get('meaning_short', '')}")
examples = entry.get("example_usage") or []
if examples:
lines.append(f" example: {examples[0]}")
lines.append(f" match: {entry.get('match_reason', '?')}")
lines.append(f" url: {entry.get('namuwiki_url', '')}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def _split_csv(value: str | None) -> list[str]:
if not value:
return []
return [part.strip() for part in value.split(",") if part.strip()]
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Search curated Korean trending-slang index. "
"Returns candidates the calling agent can use when writing text with slang."
)
)
parser.add_argument(
"--query", default=None, help="Keyword to match against term/aliases."
)
parser.add_argument(
"--mood",
default="",
help="Comma-separated mood tags (긍정, 부정, 유머, 의지, ...).",
)
parser.add_argument(
"--context",
default="",
help="Comma-separated context tags (SNS, 마케팅, 음식, 스포츠, ...).",
)
parser.add_argument(
"--safety",
default="",
help="Comma-separated safety levels: safe, spicy, risky.",
)
parser.add_argument(
"--intensity",
default="",
help="Comma-separated intensity levels: subtle, medium, strong.",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help=f"Max candidates to return (1..{MAX_LIMIT}).",
)
parser.add_argument(
"--include-deprecated",
action="store_true",
help="Include entries marked still_usable=false.",
)
parser.add_argument(
"--index-path",
default=None,
help="Override path to a slang index JSON (defaults to bundled seed).",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
help="Output format. Default: json.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv if argv is not None else sys.argv[1:])
try:
result = search(
query=args.query,
mood=_split_csv(args.mood),
context=_split_csv(args.context),
safety=_split_csv(args.safety),
intensity=_split_csv(args.intensity),
limit=args.limit,
include_deprecated=args.include_deprecated,
index_path=args.index_path,
)
except (FileNotFoundError, ValueError) as error:
print(
json.dumps({"error": str(error)}, ensure_ascii=False),
file=sys.stderr,
)
return 1
if args.format == "json":
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
sys.stdout.write(_format_text(result))
return 0
if __name__ == "__main__":
raise SystemExit(main())

216
lh-notice-search/SKILL.md Normal file
View file

@ -0,0 +1,216 @@
---
name: lh-notice-search
description: Search official LH 청약 (Korea Land & Housing Corporation lease/subscription) 공고 lists through k-skill-proxy. Use when a user asks about 청년/행복/영구/국민/매입/전세임대, 분양주택, 신혼희망타운 공고 marketed on apply.lh.or.kr.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# LH 청약 공고문 조회
## What this skill does
한국토지주택공사(LH)가 `apply.lh.or.kr` 로 공고하는 **임대주택·분양주택·주거복지(신혼희망타운 등)·토지·상가 공고**를 공공데이터포털(`data.go.kr`)의 공식 LH 공고 Open API로 조회한다. 요청은 `k-skill-proxy``/v1/lh-notice/*` 라우트로 보내고, 결과는 공고 목록·공고 상세(주택형별 공급 정보)로 정리한다.
본 스킬은 사회초년생, 청년, 신혼부부처럼 **LH 공고 존재 자체를 모르는 사용자**가 공고 마감 전에 공고문을 빠르게 찾을 수 있도록 돕는다.
## When to use
- "LH 영구임대 공고 지금 뭐 올라와 있어?"
- "신혼희망타운 공고 요즘 나온 거 정리해줘"
- "부산광역시 LH 임대주택 공고중인 것 보여줘"
- "전세임대 공고 중 마감 임박한 거 찾아줘"
- "공고번호 2015122300019828 상세 정보 보여줘"
- "행복주택 청년 모집 공고 요약해줘"
## When not to use
- 개별 사용자의 자격 심사, 당첨 예측, 가점 계산
- 청약 신청 자동화/자동 제출 (본 스킬은 read-only 조회다)
- 청약통장·주택도시기금 계좌 업무 (해당 범위는 LH 공고와 별개다)
- SH(서울주택도시공사)·GH(경기주택도시공사)·iH(인천도시공사) 전용 공고 (본 스킬은 LH 공고만 다룬다)
## Inputs
- `panSs` (또는 `status`): 공고 상태. `공고중`, `접수중`, `접수마감`, `당첨자발표`, `추정공고` 중 하나. 비우면 전체.
- `uppAisTpCd` (또는 `category`): 주택 대분류. `01`(토지), `05`(분양주택), `06`(임대주택), `13`(주거복지·신혼희망타운), `22`(상가).
- `aisTpCd`: 세부 분류 코드 (숫자). 예: `09`=영구임대, `10`=행복주택, `17`=전세임대.
- `cnpCdNm` (또는 `region`): 지역명. 예: `서울특별시`, `부산광역시`, `전국`.
- `panNm` (또는 `q`, `keyword`): 공고명 부분 검색 키워드. 예: `행복주택`, `청년`, `든든주택`.
- `panNtStDt` (또는 `startDate`): 공고 게시일 시작. YYYY-MM-DD / YYYYMMDD / YYYY.MM.DD 모두 허용.
- `clsgDt` (또는 `endDate`): 접수 마감일 종료. 날짜 포맷 동일.
- `page` (기본 1, 최대 1000), `pageSize` (기본 50, 최대 1000).
상세 조회 (`/v1/lh-notice/detail`) 는 `panId`, `ccrCnntSysDsCd`, `splInfTpCd` 세 값 모두 필수다. 이 값은 목록 응답의 `pan_id`, `ccr_cnnt_sys_ds_cd`, `spl_inf_tp_cd` 를 그대로 쓰면 된다.
## Prerequisites
- 인터넷 연결
- `curl` (또는 동일한 HTTP 호출이 가능한 도구)
사용자에게 필요한 시크릿은 없다. 공공데이터포털 `DATA_GO_KR_API_KEY``k-skill-proxy` 서버 쪽에만 둔다.
## Default path
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값, 없으면 기본 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
BASE="${BASE%/}"
```
## Supported endpoints
### 공고 목록 조회
```
GET /v1/lh-notice/search
```
필터는 선택사항이다. 필터 없이 호출하면 최근 공고를 상태 무관하게 최대 50건 돌려준다. 마감 임박한 공고만 보고 싶다면 `panSs=공고중``clsgDt` 로 구간을 좁힌다.
### 공고 상세 조회
```
GET /v1/lh-notice/detail?panId={공고ID}&ccrCnntSysDsCd={연계시스템코드}&splInfTpCd={공급정보유형코드}
```
상세 응답은 `notice`(공고 요약) + `supply_infos`(주택형/필지/상가호 별 공급 정보 배열) 를 돌려준다.
## Example requests
### 목록 — 부산 영구임대 공고중
```bash
curl -fsS --get "${BASE}/v1/lh-notice/search" \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
### 목록 — 키워드 "행복주택" 으로 접수중
```bash
curl -fsS --get "${BASE}/v1/lh-notice/search" \
--data-urlencode 'q=행복주택' \
--data-urlencode 'status=접수중'
```
### 상세 — 특정 공고
```bash
curl -fsS --get "${BASE}/v1/lh-notice/detail" \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
## Response shape
### 목록 응답
```json
{
"items": [
{
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"upp_ais_tp_cd": "06",
"ais_tp_cd": "09",
"ais_tp_cd_nm": "영구임대",
"cnp_cd_nm": "부산광역시",
"pan_ss": "공고중",
"pan_dt": "2026-04-21",
"clsg_dt": "2026-05-06",
"rcrit_pblanc_dt": null,
"spl_inf_tp_cd": "051",
"ccr_cnnt_sys_ds_cd": "03",
"detail_url": "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019828&..."
}
],
"summary": {
"page": 1,
"page_size": 20,
"returned_count": 1,
"total_count": 1
},
"query": {
"pan_ss": "공고중",
"upp_ais_tp_cd": "06",
"cnp_cd_nm": "부산광역시"
},
"proxy": {
"name": "k-skill-proxy",
"cache": { "hit": false, "ttl_ms": 300000 }
}
}
```
### 상세 응답
```json
{
"notice": {
"pan_id": "2015122300019828",
"pan_nm": "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
"ais_tp_cd_nm": "영구임대",
"...": "목록 응답과 동일한 필드"
},
"supply_infos": [
{ "HOUSE_TY": "영구임대 29㎡", "SPL_CNT": "120" },
{ "HOUSE_TY": "영구임대 39㎡", "SPL_CNT": "80" }
],
"query": {
"pan_id": "2015122300019828",
"ccr_cnnt_sys_ds_cd": "03",
"spl_inf_tp_cd": "051"
},
"proxy": { "...": "..." }
}
```
## Response policy
- 공식 LH 공고(`apply.lh.or.kr`) 정보만 사용한다. 커뮤니티 요약, 블로그 후기, 사설 부동산 정보는 섞지 않는다.
- **마감 여부는 KST 기준 현재 날짜와 `clsg_dt` 를 비교해 판정**한다. 오늘 = 마감일이면 "오늘 마감"으로 표기한다.
- 상세 응답의 `detail_url` 을 항상 함께 보여 준다. 사용자는 공고문 원본으로 바로 접근할 수 있어야 한다.
- 공고번호(`pan_id`) 를 숨기지 말고 요약에 포함한다. 이후 상세 조회에 그대로 쓴다.
- **본 스킬은 SH·GH·iH 공고를 포함하지 않는다.** 사용자가 서울시·경기도·인천시 공사 공고를 찾으면 본 스킬로는 못 찾는다는 점을 분명히 말한다.
## Keep the answer compact
사용자에게 돌려줄 때는 이렇게 압축한다.
- 필터 요약: 지역 + 공고 유형 + 상태
- 결과 건수 (`summary.total_count``returned_count`)
- 상위 3-5건 대표 공고: 공고명, 지역, 공고일, 마감일, 상태, 링크
- 마감 임박(D-3 이하) 공고는 별도로 강조
- 상세 조회 제안: "공고번호 X 상세 보고 싶으면 `lh-notice/detail` 로 조회"
## Failure modes
- 필터 값이 잘못되면 `400 bad_request` 가 돌아온다. 오류 메시지를 그대로 노출해 사용자가 교정하게 한다.
- 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 `503 upstream_not_configured` 가 돌아온다.
- upstream(공공데이터포털) 이 일시 장애이면 `502 upstream_error` + `upstream_code` 를 돌려준다. 재시도는 캐시되지 않으므로 바로 다시 호출해도 된다.
- upstream 이 XML 에러 envelope(`OpenAPI_ServiceResponse`) 를 돌려주면 `502 upstream_error` + `upstream_code`(예: `30` = 등록되지 않은 서비스키) 로 변환한다.
- 응답이 JSON 이 아니면 `502 upstream_invalid_payload` 로 내려간다.
## Done when
- 사용자의 지역·공고 유형·상태 의도에 맞춰 적어도 한 번 `/v1/lh-notice/search` 를 호출했다.
- 결과에 공고명, 지역, 공고일/마감일, 상태, 공식 링크가 모두 포함되어 있다.
- 마감 여부를 KST 기준으로 판정해 표기했다.
- 필요하면 상세 조회로 이어가거나 사용자가 스스로 상세를 조회할 수 있도록 `panId`/`ccrCnntSysDsCd`/`splInfTpCd` 를 함께 안내했다.
## Notes
- 공식 LH 청약플러스 포털: `https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancList.do?mi=1026`
- 공공데이터포털 카탈로그: `https://www.data.go.kr/data/15058530/openapi.do` (LH 임대공고문 정보)
- upstream `panSs` 값은 한국어로 정확히 맞춰서 보낸다. 영문/공백 변형은 받지 않는다.
- 대분류/세부분류 코드 매핑 참고:
- `06` 임대주택: `09` 영구임대, `10` 행복주택, `17` 전세임대, `08` 국민임대, `26` 매입임대 등
- `13` 주거복지: `17` 전세임대, 신혼희망타운 등
- `05` 분양주택, `01` 토지, `22` 상가

113
naver-news-search/SKILL.md Normal file
View file

@ -0,0 +1,113 @@
---
name: naver-news-search
description: 네이버 검색 Open API 뉴스 검색(news.json)을 k-skill-proxy 경유로 조회해 최신 뉴스 기사 제목·발행시각·링크·요약을 보수적으로 정리한다. 사용자는 별도 API 키 발급 없이 호출한다.
license: MIT
metadata:
category: information
locale: ko-KR
phase: v1
---
# Naver News Search
## What this skill does
`k-skill-proxy`가 네이버 검색 Open API 뉴스 검색(`openapi.naver.com/v1/search/news.json`)을 호출해 최근 뉴스 기사 후보를 정규화된 JSON 으로 돌려준다.
- 검색어 기반 최신 뉴스 후보 목록을 정리한다.
- 기사 제목, 본문 요약(description), 발행 시각(`pub_date`/`pub_date_iso`), 네이버 뉴스 링크(`link`), 원문 링크(`original_link`)를 제공한다.
- Naver 가 응답에 섞어주는 `<b>` 하이라이트 태그와 HTML entity(`&amp;`, `&quot;`, `&lt;` 등)는 proxy 쪽에서 미리 제거한다.
- 사용자 로그인·개인화·회원 전용 뉴스는 지원하지 않는다.
## When to use
- "오늘 삼성전자 관련 뉴스 찾아줘"
- "최근 AI 규제 관련 기사 최신순으로 5개만"
- "네이버 뉴스에서 금리 인상 기사 요약해줘"
- "이 사건 기사 링크 정리해줘"
## When not to use
- 특정 언론사 내부 유료 기사, 로그인 뒤에만 보이는 기사
- 기사 본문 전체가 필요한 경우 (API 는 요약 description 만 제공)
- 주식/환율/부동산 실시간 시세 (뉴스 API 는 기사만 다룬다)
- 차단/CAPTCHA 우회가 필요한 경로
## Required inputs
검색어(`q` / `query`)가 없으면 먼저 물어본다.
권장 질문:
> 찾을 네이버 뉴스 검색어를 알려주세요. 예: "삼성전자 실적", "인공지능 규제", "금리 인상"
단어 2글자 미만이면 의미가 불분명하므로 되묻는다.
## Proxy endpoint
기본값은 public/read-only/no-auth 프록시다. 사용자는 **네이버 개발자 센터 Client ID/Secret 을 발급받지 않아도 된다**. upstream key(`NAVER_SEARCH_CLIENT_ID` / `NAVER_SEARCH_CLIENT_SECRET`)는 프록시 서버에서만 주입한다.
```bash
curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v1/naver-news/search" \
--data-urlencode 'q=삼성전자 실적' \
--data-urlencode 'display=10' \
--data-urlencode 'sort=date'
```
쿼리 파라미터:
- `q` 또는 `query` — 검색어. 2글자 이상.
- `display` — 반환 건수. 기본 10, 범위 1~100.
- `start` — 검색 시작 위치(1-indexed). 기본 1, 최대 1000. **`start + display - 1` 은 1000 을 넘을 수 없다**: 예를 들어 `start=1000 & display=100``1099`번째 아이템을 요구하므로 proxy가 업스트림 호출 전에 `400 bad_request`("start + display exceeds Naver's 1000-item search window")로 거절한다. 아주 오래된 기사를 찾으려면 검색어를 좁히는 것이 낫다.
- `sort``sim`(유사도 순, 기본값) 또는 `date`(최신순). 그 외 값은 `sim` 으로 fallback.
응답 주요 필드:
- `items[].title``<b>` 태그·HTML entity 가 제거된 기사 제목
- `items[].description``<b>` 태그·HTML entity 가 제거된 기사 요약
- `items[].link` — 네이버 뉴스 redirect 링크
- `items[].original_link` — 원문 뉴스 링크(빈 문자열이면 `null`)
- `items[].pub_date` — 원본 RFC822 형식 발행 시각
- `items[].pub_date_iso` — 파싱된 ISO-8601(UTC) 발행 시각. 파싱 실패시 `null`
- `meta.extraction` — 항상 `naver-openapi`
- `meta.total`, `meta.start`, `meta.display`, `meta.last_build_date`, `meta.sort`
## Workflow
1. 검색어를 확인한다. (없거나 2글자 미만이면 먼저 물어본다)
2. 사용자가 "최신순"을 원하면 `sort=date`, 그 외에는 `sort=sim` 으로 호출한다.
3. `GET /v1/naver-news/search` 를 호출한다.
4. `items` 가 있으면 상위 3~5건을 제목, 발행 시각(KST 기준으로 재포맷해도 좋다), 요약, 링크로 짧게 정리한다.
5. 발행 시각은 `pub_date_iso` 기준으로 오늘/어제 표기를 붙여도 된다. (KST = UTC+9)
6. `items` 가 비었거나 `upstream_error` 가 나면 재시도하지 말고 검색어를 좁혀 다시 물어본다.
## Response style
- 기사 제목/요약은 API 가 돌려준 원문만 인용한다. 원문에 없는 해설은 덧붙이지 않는다.
- 기사 발행 시각은 "KST 기준 {YYYY-MM-DD HH:mm}" 또는 "{n}시간 전" 정도로 짧게 표시한다.
- 원문 링크(`original_link`)가 있으면 우선 노출하고, 없으면 `link`(네이버 뉴스 redirect)를 안내한다.
- 서로 다른 언론사가 같은 사건을 다루면 링크 2~3개를 병렬로 제시해 사용자가 비교할 수 있게 한다.
- `description` 은 요약이므로, 팩트로 단정하지 말고 "기사 요약에 따르면"이라고 전한다.
## Failure modes
- `400 bad_request` — 검색어 누락, 2글자 미만, 허용되지 않는 파라미터, 혹은 `start + display - 1 > 1000` 조합(네이버 1000-item search window 초과). 에러 메시지를 그대로 사용자에게 노출한다.
- `503 upstream_not_configured` — 프록시 서버에 `NAVER_SEARCH_CLIENT_ID`/`NAVER_SEARCH_CLIENT_SECRET` 가 없는 경우. 운영자가 키를 등록해야 한다. 사용자에게는 "잠시 후 다시 시도해 주세요" 정도로 안내한다.
- `401 upstream_error` — 프록시 서버의 Client ID/Secret 이 잘못된 경우(`errorCode: 024`). 운영자가 재발급해야 한다.
- `429 upstream_error` — 네이버 검색 API 일일 쿼터(25,000 호출/일) 초과(`errorCode: 010`). 재시도 루프는 금지. 잠시 후 다시 시도하도록 안내한다.
- `502 upstream_error` — 네이버 API 5xx 또는 응답 JSON 파싱 실패.
- upstream 차단이나 장애 발생 시 재시도하지 않는다. cache + rate limit 만으로 대응하고, 사용자에게는 현재 조회 불가능함을 분명히 말한다.
## Privacy
- 검색어/결과를 영구 저장하지 않는다.
- 기사 본문은 요청하지 않는다. description(API 가 주는 요약)만 사용한다.
- 특정 인물·사건을 비방·추측하는 서술은 하지 않는다. 기사 원문만 전달한다.
## Done when
- 검색어를 확인했다.
- 최소 1건 이상의 기사를 제목·요약·발행 시각·링크로 정리해서 돌려주거나, 왜 결과가 없는지 설명했다.
- 발행 시각은 KST 기준으로 표시했다.
- 네이버 API 쿼터 상태·차단 발생 여부·재시도 금지 원칙을 지켰다.
- 로그인/개인화/차단 우회 범위를 벗어나지 않았다.

23
package-lock.json generated
View file

@ -448,6 +448,12 @@
"version": "0.4.0",
"license": "MIT"
},
"node_modules/@rhwp/core": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@rhwp/core/-/core-0.7.3.tgz",
"integrity": "sha512-nueIqvZbW0v84y0xnRJhwPoYE0kHUZ9gOSiBn33xW4O7VxX/WgfMTbhevXoY8c0ryIH3Zd4ba27nK062l3uW1g==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"dev": true,
@ -1003,6 +1009,10 @@
"resolved": "packages/k-skill-proxy",
"link": true
},
"node_modules/k-skill-rhwp": {
"resolved": "packages/k-skill-rhwp",
"link": true
},
"node_modules/kakao-bar-nearby": {
"resolved": "packages/kakao-bar-nearby",
"link": true
@ -1752,6 +1762,19 @@
"node": ">=18"
}
},
"packages/k-skill-rhwp": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@rhwp/core": "^0.7.3"
},
"bin": {
"k-skill-rhwp": "bin/k-skill-rhwp.js"
},
"engines": {
"node": ">=18"
}
},
"packages/kakao-bar-nearby": {
"version": "0.2.0",
"license": "MIT",

View file

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

@ -19,11 +19,24 @@
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/naver-shopping/search` — 네이버 검색 Open API 쇼핑 검색 우선, 키가 없으면 네이버 쇼핑 공개 BFF JSON 기반 상품/가격 후보 조회
- `GET /v1/naver-news/search` — 네이버 검색 Open API 뉴스 검색(`news.json`) 기반 최신 뉴스 기사 제목/요약/링크/발행시각 조회(`NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` 필요)
- `GET /v1/data4library/library-search` — 도서관 정보나루 정보공개 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-search` — 도서관 정보나루 도서 검색(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-detail` — 도서관 정보나루 도서 상세 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/libraries-by-book` — 도서 소장 도서관 조회(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/data4library/book-exists` — 도서관별 도서 소장여부(`DATA4LIBRARY_AUTH_KEY`)
- `GET /v1/lh-notice/search` — LH 청약 공고 목록(`DATA_GO_KR_API_KEY`)
- `GET /v1/lh-notice/detail` — LH 청약 공고 상세(`DATA_GO_KR_API_KEY`)
## `/health` 업스트림 플래그 의미
`/health``upstreams` 는 각 라우트의 **운영 가능 여부**를 보고하며, 같은 환경변수를 공유하는 라우트라도 **폴백 유무에 따라 의미가 달라진다**:
- `naverShoppingConfigured` — 네이버 쇼핑 라우트는 공개 BFF JSON fallback 이 있어서 **항상 `true`** 다. 키가 없어도 public BFF 경로로 응답이 나간다.
- `naverSearchApiConfigured` — 네이버 검색 Open API 키(`NAVER_SEARCH_CLIENT_ID` + `NAVER_SEARCH_CLIENT_SECRET`) 설정 여부. 네이버 쇼핑 라우트는 이 값이 `true` 면 공식 API 를 선호하고, `false` 면 BFF fallback 으로 자동 전환한다. 즉 이 플래그는 **쇼핑 쪽에서는 advisory** 다.
- `naverNewsApiConfigured` — 네이버 뉴스 라우트의 **운영 가능 여부**. 뉴스에는 fallback 이 없어서 키가 없으면 뉴스 라우트는 `503 upstream_not_configured` 를 돌려준다.
`naverSearchApiConfigured``naverNewsApiConfigured` 는 같은 환경변수에 의존하므로 현재 boolean 값은 항상 일치하지만, **의미(semantic contract)는 다르다**: 전자는 "공식 키가 있는지" 를, 후자는 "뉴스 라우트가 실제로 응답을 돌려줄 수 있는지" 를 보고한다. 향후 검색 키가 분리되거나 fallback 정책이 바뀌어도 이 두 플래그는 분리된 채 유지된다.
## 환경변수
@ -35,13 +48,13 @@
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET`선택: 네이버 검색 Open API 쇼핑 검색(`shop.json`) 키. 설정되면 네이버 쇼핑 route가 bot-block 위험이 낮은 공식 API를 우선 사용하고, 없으면 공개 BFF JSON(`ns-portal.shopping.naver.com/api/v2/shopping-paged-slot`) 파서로 fallback. 공식 API는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key fallback은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET`네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`)
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -171,6 +184,25 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'bas_dd=20260408'
```
LH 청약 공고 목록 예시 (`DATA_GO_KR_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/search' \
--data-urlencode 'panSs=공고중' \
--data-urlencode 'uppAisTpCd=06' \
--data-urlencode 'cnpCdNm=부산광역시' \
--data-urlencode 'pageSize=20'
```
LH 청약 공고 상세:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/lh-notice/detail' \
--data-urlencode 'panId=2015122300019828' \
--data-urlencode 'ccrCnntSysDsCd=03' \
--data-urlencode 'splInfTpCd=051'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.

View file

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

View file

@ -0,0 +1,538 @@
// LH 청약 (Korea Land & Housing Corporation lease/subscription notice) API wrapper.
// Proxies the official data.go.kr LH Lease Notice endpoint so the user never has to
// manage ServiceKey. The upstream base is:
// http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1 (list)
// http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1 (detail)
//
// The upstream responds with a JSON array whose shape is:
// [
// { "CMN": { "CODE": "SUCCESS", "ERR_MSG": "", "TOTAL_CNT": 123 } },
// { "dsList": [ { PAN_ID, PAN_NM, ... }, ... ] }
// ]
// Error payloads can also arrive as XML from the common data.go.kr error path, e.g.
// unregistered ServiceKey, so both JSON and XML fault paths must be handled.
const LH_UPSTREAM_BASE_URL = "http://apis.data.go.kr/B552555";
const LH_LIST_PATH = "lhLeaseNoticeInfo1/lhLeaseNoticeInfo1";
const LH_DETAIL_PATH = "lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1";
const LH_DEFAULT_LIST_URL = `${LH_UPSTREAM_BASE_URL}/${LH_LIST_PATH}`;
const LH_DEFAULT_DETAIL_URL = `${LH_UPSTREAM_BASE_URL}/${LH_DETAIL_PATH}`;
// Valid `PAN_SS` filter values (공고 상태). Users pass these in Korean exactly the
// way the upstream catalog documents them. We do NOT guess variants — if the user
// omits this filter, we pass nothing and the upstream returns every status.
const VALID_PAN_SS_VALUES = new Set([
"공고중",
"접수중",
"접수마감",
"당첨자발표",
"추정공고"
]);
// Known `UPP_AIS_TP_CD` categories (주택 대분류).
// 06 = 임대주택, 05 = 분양주택, 13 = 주거복지 (신혼희망타운 포함), 01 = 토지, 22 = 상가
const VALID_UPP_AIS_TP_CD_VALUES = new Set([
"01", "05", "06", "13", "22"
]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function parseBoundedInt(value, { defaultValue, min, max, label }) {
if (value === undefined || value === null || String(value).trim() === "") {
return defaultValue;
}
const text = String(value).trim();
if (!/^\d+$/.test(text)) {
throw new Error(`Provide valid ${label}.`);
}
const parsed = Number.parseInt(text, 10);
if (parsed < min) {
return min;
}
if (parsed > max) {
return max;
}
return parsed;
}
function normalizeDateString(value, label) {
const trimmed = trimOrNull(value);
if (trimmed === null) {
return null;
}
// Accept YYYY-MM-DD, YYYY.MM.DD, YYYYMMDD; normalize to YYYY-MM-DD per LH upstream docs.
const digits = trimmed.replace(/[.\-\/]/g, "");
if (!/^\d{8}$/.test(digits)) {
throw new Error(`Provide ${label} as YYYY-MM-DD (8 digits).`);
}
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
function normalizeLhNoticeSearchQuery(query) {
const normalized = {};
const panSs = trimOrNull(query.panSs ?? query.PAN_SS ?? query.status);
if (panSs) {
if (!VALID_PAN_SS_VALUES.has(panSs)) {
throw new Error(
`panSs must be one of: ${Array.from(VALID_PAN_SS_VALUES).join(", ")}.`
);
}
normalized.panSs = panSs;
}
const uppAisTpCd = trimOrNull(
query.uppAisTpCd ?? query.UPP_AIS_TP_CD ?? query.category
);
if (uppAisTpCd) {
if (!VALID_UPP_AIS_TP_CD_VALUES.has(uppAisTpCd)) {
throw new Error(
`uppAisTpCd must be one of: ${Array.from(VALID_UPP_AIS_TP_CD_VALUES).join(", ")}.`
);
}
normalized.uppAisTpCd = uppAisTpCd;
}
const aisTpCd = trimOrNull(query.aisTpCd ?? query.AIS_TP_CD);
if (aisTpCd) {
if (!/^[0-9]{1,4}$/.test(aisTpCd)) {
throw new Error("aisTpCd must be digits only (1-4 chars).");
}
normalized.aisTpCd = aisTpCd;
}
const cnpCdNm = trimOrNull(
query.cnpCdNm ?? query.CNP_CD_NM ?? query.region ?? query.regionName
);
if (cnpCdNm) {
normalized.cnpCdNm = cnpCdNm;
}
const panNm = trimOrNull(
query.panNm ?? query.PAN_NM ?? query.q ?? query.query ?? query.keyword
);
if (panNm) {
normalized.panNm = panNm;
}
const panNtStDt = normalizeDateString(
query.panNtStDt ?? query.PAN_NT_ST_DT ?? query.startDate,
"panNtStDt"
);
if (panNtStDt) {
normalized.panNtStDt = panNtStDt;
}
const clsgDt = normalizeDateString(
query.clsgDt ?? query.CLSG_DT ?? query.endDate,
"clsgDt"
);
if (clsgDt) {
normalized.clsgDt = clsgDt;
}
normalized.page = parseBoundedInt(query.page ?? query.PAGE ?? query.pageNo, {
defaultValue: 1,
min: 1,
max: 1000,
label: "page"
});
normalized.pageSize = parseBoundedInt(
query.pageSize ?? query.PG_SZ ?? query.numOfRows ?? query.limit,
{
defaultValue: 50,
min: 1,
max: 1000,
label: "pageSize"
}
);
return normalized;
}
function normalizeLhNoticeDetailQuery(query) {
const panId = trimOrNull(query.panId ?? query.PAN_ID);
if (!panId) {
throw new Error("Provide panId.");
}
if (!/^[0-9]{4,20}$/.test(panId)) {
throw new Error("panId must be digits only.");
}
const ccrCnntSysDsCd = trimOrNull(
query.ccrCnntSysDsCd ?? query.CCR_CNNT_SYS_DS_CD ?? query.systemCode
);
if (!ccrCnntSysDsCd) {
throw new Error("Provide ccrCnntSysDsCd.");
}
if (!/^[0-9]{1,4}$/.test(ccrCnntSysDsCd)) {
throw new Error("ccrCnntSysDsCd must be digits only (1-4 chars).");
}
const splInfTpCd = trimOrNull(
query.splInfTpCd ?? query.SPL_INF_TP_CD ?? query.supplyTypeCode
);
if (!splInfTpCd) {
throw new Error("Provide splInfTpCd.");
}
if (!/^[0-9]{1,6}$/.test(splInfTpCd)) {
throw new Error("splInfTpCd must be digits only (1-6 chars).");
}
return { panId, ccrCnntSysDsCd, splInfTpCd };
}
function normalizeNoticeItem(raw) {
if (!raw || typeof raw !== "object") {
return null;
}
// Upstream delivers keys in UPPER_SNAKE_CASE. Map to lower snake_case so the
// proxy response matches other k-skill-proxy JSON shapes.
const panId = trimOrNull(raw.PAN_ID ?? raw.panId);
const panNm = trimOrNull(raw.PAN_NM ?? raw.panNm);
if (!panId && !panNm) {
return null;
}
const uppAisTpCd = trimOrNull(raw.UPP_AIS_TP_CD ?? raw.uppAisTpCd);
const aisTpCd = trimOrNull(raw.AIS_TP_CD ?? raw.aisTpCd);
const aisTpCdNm = trimOrNull(raw.AIS_TP_CD_NM ?? raw.aisTpCdNm);
const cnpCdNm = trimOrNull(raw.CNP_CD_NM ?? raw.cnpCdNm);
const panSs = trimOrNull(raw.PAN_SS ?? raw.panSs);
const panDt = trimOrNull(raw.PAN_DT ?? raw.panDt ?? raw.PAN_NT_ST_DT ?? raw.panNtStDt);
const clsgDt = trimOrNull(raw.CLSG_DT ?? raw.clsgDt ?? raw.PAN_NT_ED_DT ?? raw.panNtEdDt);
const rcritPblancDt = trimOrNull(raw.RCRIT_PBLANC_DT ?? raw.rcritPblancDt);
const dtlUrl = trimOrNull(raw.DTL_URL ?? raw.dtlUrl);
const splInfTpCd = trimOrNull(raw.SPL_INF_TP_CD ?? raw.splInfTpCd);
const ccrCnntSysDsCd = trimOrNull(raw.CCR_CNNT_SYS_DS_CD ?? raw.ccrCnntSysDsCd);
return {
pan_id: panId,
pan_nm: panNm,
upp_ais_tp_cd: uppAisTpCd,
ais_tp_cd: aisTpCd,
ais_tp_cd_nm: aisTpCdNm,
cnp_cd_nm: cnpCdNm,
pan_ss: panSs,
pan_dt: panDt,
clsg_dt: clsgDt,
rcrit_pblanc_dt: rcritPblancDt,
spl_inf_tp_cd: splInfTpCd,
ccr_cnnt_sys_ds_cd: ccrCnntSysDsCd,
detail_url: dtlUrl,
raw
};
}
// data.go.kr services return XML on common auth/parameter errors even when JSON
// is requested. Pull resultCode/resultMsg and surface them as a structured error.
function parseXmlErrorEnvelope(xmlText) {
if (typeof xmlText !== "string" || xmlText.length === 0) {
return null;
}
if (!/<OpenAPI_ServiceResponse>|<response[\s>]|<returnAuthMsg>|<errMsg>/i.test(xmlText)) {
return null;
}
const codeMatch = xmlText.match(/<resultCode>([^<]*)<\/resultCode>/i)
|| xmlText.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/i);
const msgMatch = xmlText.match(/<resultMsg>([^<]*)<\/resultMsg>/i)
|| xmlText.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/i)
|| xmlText.match(/<errMsg>([^<]*)<\/errMsg>/i);
const code = codeMatch ? codeMatch[1].trim() : null;
const message = msgMatch ? msgMatch[1].trim() : "LH upstream returned an error envelope.";
if (!code && !msgMatch) {
return null;
}
return {
code: code || "unknown",
message
};
}
function buildError({ message, statusCode, code, upstreamCode }) {
const error = new Error(message);
error.statusCode = statusCode;
error.code = code;
if (upstreamCode) {
error.upstreamCode = upstreamCode;
}
return error;
}
// Parse the LH JSON envelope into { totalCount, items, raw }.
// The upstream envelope shape is a 2-element array:
// [ { CMN: { CODE, ERR_MSG, TOTAL_CNT } }, { dsList: [...] } ]
// but the proxy is resilient to the more common data.go.kr shape
// `{ response: { body: { items: [...], totalCount } } }` as well.
function extractNoticeEnvelope(parsed) {
if (Array.isArray(parsed)) {
const head = parsed[0] || {};
const body = parsed[1] || {};
const cmn = head.CMN || head.cmn || {};
const code = trimOrNull(cmn.CODE ?? cmn.code);
const errMsg = trimOrNull(cmn.ERR_MSG ?? cmn.errMsg);
// The LH catalog has historically surfaced `CMN.CODE` as one of
// `"SUCCESS"`, `"0"`, `"00"`, or `"000"` across different data.go.kr
// platform eras. Treating all four as success is deliberate — NOT
// redundant — so that a future data.go.kr normalization that flips the
// code from `"SUCCESS"` to a numeric form does not start 502'ing
// otherwise-valid responses. See tests `extractNoticeEnvelope treats
// array-envelope CMN.CODE="…" as success` for coverage.
if (code && code !== "SUCCESS" && code !== "0" && code !== "00" && code !== "000") {
throw buildError({
message: errMsg || `LH upstream rejected the request (${code}).`,
statusCode: 502,
code: "upstream_error",
upstreamCode: code
});
}
const totalCount = Number.isFinite(Number(cmn.TOTAL_CNT ?? cmn.totalCount))
? Number(cmn.TOTAL_CNT ?? cmn.totalCount)
: null;
const rawItems = Array.isArray(body.dsList)
? body.dsList
: Array.isArray(body.DS_LIST)
? body.DS_LIST
: [];
return { totalCount, items: rawItems };
}
if (parsed && typeof parsed === "object") {
const response = parsed.response || parsed;
const header = response.header || {};
const headerCode = trimOrNull(header.resultCode);
if (headerCode && !["00", "000", "0"].includes(headerCode)) {
throw buildError({
message: trimOrNull(header.resultMsg) || `LH upstream rejected the request (${headerCode}).`,
statusCode: 502,
code: "upstream_error",
upstreamCode: headerCode
});
}
const body = response.body || {};
const rawItems = Array.isArray(body.items)
? body.items
: Array.isArray(body.item)
? body.item
: Array.isArray(body.items?.item)
? body.items.item
: [];
const totalCount = Number.isFinite(Number(body.totalCount))
? Number(body.totalCount)
: null;
return { totalCount, items: rawItems };
}
return { totalCount: null, items: [] };
}
function buildNoticeListResponseBody(envelope, { page, pageSize, filters }) {
const items = [];
for (const raw of envelope.items) {
const normalized = normalizeNoticeItem(raw);
if (normalized) {
items.push(normalized);
}
}
return {
items,
summary: {
page,
page_size: pageSize,
returned_count: items.length,
total_count: envelope.totalCount
},
query: filters
};
}
function buildNoticeDetailResponseBody(envelope, filters) {
// Detail envelope can return multiple supply rows (per 주택형). Keep them all as
// `supply_infos` plus a normalized `notice` summary using the first row.
const supplyInfos = envelope.items.slice();
const first = supplyInfos[0] || {};
const notice = normalizeNoticeItem(first) || {
pan_id: filters.panId,
pan_nm: null,
upp_ais_tp_cd: null,
ais_tp_cd: null,
ais_tp_cd_nm: null,
cnp_cd_nm: null,
pan_ss: null,
pan_dt: null,
clsg_dt: null,
rcrit_pblanc_dt: null,
spl_inf_tp_cd: filters.splInfTpCd,
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
detail_url: null,
raw: first
};
return {
notice,
supply_infos: supplyInfos,
query: filters
};
}
async function fetchLhUpstream({ url, fetchImpl = global.fetch, timeoutMs = 20000 }) {
let response;
try {
response = await fetchImpl(url.toString(), {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(timeoutMs)
});
} catch (err) {
throw buildError({
message: `LH upstream request failed: ${err.message}`,
statusCode: 502,
code: "upstream_fetch_failed"
});
}
const text = await response.text();
if (!response.ok) {
const xmlError = parseXmlErrorEnvelope(text);
if (xmlError) {
throw buildError({
message: xmlError.message,
statusCode: response.status === 401 ? 503 : 502,
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error",
upstreamCode: xmlError.code
});
}
throw buildError({
message: `LH upstream responded with HTTP ${response.status}: ${text.slice(0, 200)}`,
statusCode: response.status === 401 ? 503 : 502,
code: response.status === 401 ? "upstream_not_authorized" : "upstream_error"
});
}
// data.go.kr sometimes returns 200 + XML envelope carrying the real error.
const xmlError = parseXmlErrorEnvelope(text);
if (xmlError) {
throw buildError({
message: xmlError.message,
statusCode: 502,
code: "upstream_error",
upstreamCode: xmlError.code
});
}
let parsed;
try {
parsed = JSON.parse(text);
} catch (err) {
throw buildError({
message: `LH upstream returned non-JSON payload (first 200 chars): ${text.slice(0, 200)}`,
statusCode: 502,
code: "upstream_invalid_payload"
});
}
return parsed;
}
async function fetchLhNoticeList({ serviceKey, filters, fetchImpl = global.fetch }) {
const url = new URL(LH_DEFAULT_LIST_URL);
url.searchParams.set("serviceKey", serviceKey);
// Upstream uses SCREAMING_SNAKE_CASE param names (PG_SZ, PAGE, PAN_SS, etc.).
url.searchParams.set("PG_SZ", String(filters.pageSize));
url.searchParams.set("PAGE", String(filters.page));
if (filters.panSs) {
url.searchParams.set("PAN_SS", filters.panSs);
}
if (filters.uppAisTpCd) {
url.searchParams.set("UPP_AIS_TP_CD", filters.uppAisTpCd);
}
if (filters.aisTpCd) {
url.searchParams.set("AIS_TP_CD", filters.aisTpCd);
}
if (filters.cnpCdNm) {
url.searchParams.set("CNP_CD_NM", filters.cnpCdNm);
}
if (filters.panNm) {
url.searchParams.set("PAN_NM", filters.panNm);
}
if (filters.panNtStDt) {
url.searchParams.set("PAN_NT_ST_DT", filters.panNtStDt);
}
if (filters.clsgDt) {
url.searchParams.set("CLSG_DT", filters.clsgDt);
}
const parsed = await fetchLhUpstream({ url, fetchImpl });
const envelope = extractNoticeEnvelope(parsed);
return buildNoticeListResponseBody(envelope, {
page: filters.page,
pageSize: filters.pageSize,
filters: {
pan_ss: filters.panSs || null,
upp_ais_tp_cd: filters.uppAisTpCd || null,
ais_tp_cd: filters.aisTpCd || null,
cnp_cd_nm: filters.cnpCdNm || null,
pan_nm: filters.panNm || null,
pan_nt_st_dt: filters.panNtStDt || null,
clsg_dt: filters.clsgDt || null
}
});
}
async function fetchLhNoticeDetail({ serviceKey, filters, fetchImpl = global.fetch }) {
const url = new URL(LH_DEFAULT_DETAIL_URL);
url.searchParams.set("serviceKey", serviceKey);
url.searchParams.set("PAN_ID", filters.panId);
url.searchParams.set("CCR_CNNT_SYS_DS_CD", filters.ccrCnntSysDsCd);
url.searchParams.set("SPL_INF_TP_CD", filters.splInfTpCd);
const parsed = await fetchLhUpstream({ url, fetchImpl });
const envelope = extractNoticeEnvelope(parsed);
return buildNoticeDetailResponseBody(envelope, {
pan_id: filters.panId,
ccr_cnnt_sys_ds_cd: filters.ccrCnntSysDsCd,
spl_inf_tp_cd: filters.splInfTpCd
});
}
module.exports = {
LH_UPSTREAM_BASE_URL,
LH_LIST_PATH,
LH_DETAIL_PATH,
LH_DEFAULT_LIST_URL,
LH_DEFAULT_DETAIL_URL,
VALID_PAN_SS_VALUES,
VALID_UPP_AIS_TP_CD_VALUES,
normalizeLhNoticeSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeNoticeItem,
parseXmlErrorEnvelope,
extractNoticeEnvelope,
buildNoticeListResponseBody,
buildNoticeDetailResponseBody,
fetchLhNoticeList,
fetchLhNoticeDetail
};

View file

@ -0,0 +1,261 @@
const NAVER_NEWS_OPEN_API_URL = "https://openapi.naver.com/v1/search/news.json";
const DEFAULT_DISPLAY = 10;
const MIN_DISPLAY = 1;
const MAX_DISPLAY = 100;
const DEFAULT_START = 1;
const MIN_START = 1;
const MAX_START = 1000;
const MAX_SEARCH_WINDOW = 1000;
const ALLOWED_SORTS = new Set(["sim", "date"]);
function parseInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function decodeHtmlEntities(value) {
if (value === undefined || value === null) {
return "";
}
return String(value)
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number.parseInt(code, 10)))
.replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(Number.parseInt(code, 16)))
.replace(/&amp;/g, "&");
}
function stripTags(value) {
return decodeHtmlEntities(value)
.replace(/<\/?[^>]+(>|$)/g, "")
.replace(/\s+/g, " ")
.trim();
}
function normalizeString(value) {
const normalized = stripTags(value);
return normalized || null;
}
function normalizeUrl(value) {
const raw = trimOrNull(value);
if (!raw || /^javascript:/i.test(raw)) {
return null;
}
if (/^https?:\/\//i.test(raw)) {
return raw;
}
return null;
}
function canonicalizeLinkForDedup(link) {
try {
const u = new URL(link);
const params = [...u.searchParams.entries()];
params.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
u.search = params.length ? new URLSearchParams(params).toString() : "";
if (u.pathname.length > 1 && u.pathname.endsWith("/")) {
u.pathname = u.pathname.replace(/\/+$/, "") || "/";
}
u.hash = "";
return u.toString().toLowerCase();
} catch {
return String(link).toLowerCase();
}
}
function parsePubDateIso(rfc822) {
if (!rfc822) {
return null;
}
const date = new Date(rfc822);
if (Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString();
}
function normalizeNaverNewsSearchQuery(query) {
const q = trimOrNull(query?.q ?? query?.query ?? query?.keyword);
if (!q) {
throw new Error("Provide q/query.");
}
if ([...q].length < 2) {
throw new Error("q/query must be at least 2 characters.");
}
const rawDisplay = parseInteger(query.display ?? query.limit ?? query.size, DEFAULT_DISPLAY);
const rawStart = parseInteger(query.start ?? query.offset, DEFAULT_START);
const requestedSort = trimOrNull(query.sort) || "sim";
const sort = ALLOWED_SORTS.has(requestedSort) ? requestedSort : "sim";
const display = clamp(rawDisplay, MIN_DISPLAY, MAX_DISPLAY);
const start = clamp(rawStart, MIN_START, MAX_START);
if (start + display - 1 > MAX_SEARCH_WINDOW) {
throw new Error(
`start + display exceeds Naver's ${MAX_SEARCH_WINDOW}-item search window ` +
`(start=${start} + display=${display} would fetch item ${start + display - 1}, ` +
`max accessible item is ${MAX_SEARCH_WINDOW}). Narrow the search or reduce start/display.`
);
}
return {
query: q,
display,
start,
sort
};
}
function buildNaverNewsSearchUrl({ query, display = DEFAULT_DISPLAY, start = DEFAULT_START, sort = "sim" } = {}) {
const url = new URL(NAVER_NEWS_OPEN_API_URL);
url.searchParams.set("query", query);
url.searchParams.set("display", String(display));
url.searchParams.set("start", String(start));
url.searchParams.set("sort", sort);
return url;
}
function normalizeNaverNewsSearchPayload(
payload,
{ query = null, display = DEFAULT_DISPLAY, start = DEFAULT_START, sort = "sim" } = {}
) {
const items = Array.isArray(payload?.items) ? payload.items : [];
const normalized = [];
const seenLinks = new Set();
const normalizedSort = ALLOWED_SORTS.has(sort) ? sort : "sim";
for (const item of items) {
const title = normalizeString(item.title);
const link = normalizeUrl(item.link);
if (!title || !link) {
continue;
}
const originalLink = normalizeUrl(item.originallink);
const description = normalizeString(item.description);
const pubDate = trimOrNull(item.pubDate);
const pubDateIso = parsePubDateIso(pubDate);
const dedupKey = canonicalizeLinkForDedup(link);
if (seenLinks.has(dedupKey)) {
continue;
}
seenLinks.add(dedupKey);
normalized.push({
rank: normalized.length + 1,
title,
description,
link,
original_link: originalLink,
pub_date: pubDate,
pub_date_iso: pubDateIso,
source: "naver-openapi"
});
}
return {
items: normalized,
meta: {
query,
extraction: "naver-openapi",
item_count: normalized.length,
total: parseInteger(payload?.total, 0),
start: parseInteger(payload?.start, start),
display: parseInteger(payload?.display, display),
last_build_date: normalizeString(payload?.lastBuildDate),
sort: normalizedSort
}
};
}
async function fetchNaverNewsSearch({
query,
display = DEFAULT_DISPLAY,
start = DEFAULT_START,
sort = "sim",
clientId,
clientSecret,
fetchImpl = global.fetch
} = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("fetch is not available in this Node runtime.");
}
if (!clientId || !clientSecret) {
const error = new Error(
"NAVER_SEARCH_CLIENT_ID and NAVER_SEARCH_CLIENT_SECRET are not configured on the proxy server."
);
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = buildNaverNewsSearchUrl({ query, display, start, sort });
const response = await fetchImpl(url, {
headers: {
"X-Naver-Client-Id": clientId,
"X-Naver-Client-Secret": clientSecret,
accept: "application/json"
},
signal: AbortSignal.timeout(15000)
});
const body = await response.text();
if (!response.ok) {
const error = new Error(`Naver News Search API responded with ${response.status}.`);
error.code = "upstream_error";
error.statusCode = response.status >= 400 && response.status < 500 ? response.status : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = body.slice(0, 200);
throw error;
}
let payload;
try {
payload = JSON.parse(body);
} catch (cause) {
const error = new Error("Naver News Search API returned invalid JSON.");
error.code = "invalid_upstream_response";
error.statusCode = 502;
error.cause = cause;
throw error;
}
const parsed = normalizeNaverNewsSearchPayload(payload, { query, display, start, sort });
return {
...parsed,
upstream: {
url: url.toString(),
status_code: response.status,
content_type: response.headers.get("content-type") || null,
provider: "naver-search-api"
}
};
}
module.exports = {
buildNaverNewsSearchUrl,
fetchNaverNewsSearch,
normalizeNaverNewsSearchPayload,
normalizeNaverNewsSearchQuery
};

View file

@ -13,7 +13,14 @@ const {
normalizeMfdsDrugLookupQuery,
normalizeMfdsFoodSafetyQuery
} = require("./mfds");
const {
fetchLhNoticeDetail,
fetchLhNoticeList,
normalizeLhNoticeDetailQuery,
normalizeLhNoticeSearchQuery
} = require("./lh-notice");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
@ -728,6 +735,7 @@ function normalizeParkingLotSearchQuery(query) {
throw new Error("radius must be between 1 and 50000.");
}
const publicOnlyRaw = trimOrNull(query.publicOnly ?? query.public_only);
const publicOnly = publicOnlyRaw
? !["0", "false", "n", "no"].includes(publicOnlyRaw.toLowerCase())
@ -1257,30 +1265,35 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
}
});
app.get("/health", async () => ({
ok: true,
service: config.proxyName,
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
data4libraryConfigured: Boolean(config.data4libraryAuthKey),
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: Boolean(config.naverSearchClientId && config.naverSearchClientSecret)
},
auth: {
tokenRequired: false
},
timestamp: new Date().toISOString()
}));
app.get("/health", async () => {
const naverSearchKeysPresent = Boolean(config.naverSearchClientId && config.naverSearchClientSecret);
return {
ok: true,
service: config.proxyName,
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
lhNoticeConfigured: Boolean(config.molitApiKey),
data4libraryConfigured: Boolean(config.data4libraryAuthKey),
foodsafetyKoreaConfigured: Boolean(config.foodsafetyKoreaApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
krxConfigured: Boolean(config.krxApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent
},
auth: {
tokenRequired: false
},
timestamp: new Date().toISOString()
};
});
app.get("/B552584/:service/:operation", async (request, reply) => {
const { service, operation } = request.params;
@ -2077,6 +2090,168 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/lh-notice/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeLhNoticeSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "lh-notice-search",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let body;
try {
body = await fetchLhNoticeList({
serviceKey: config.molitApiKey,
filters: normalized
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
upstream_code: error.upstreamCode || undefined,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
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/lh-notice/detail", async (request, reply) => {
let normalized;
try {
normalized = normalizeLhNoticeDetailQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "lh-notice-detail",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let body;
try {
body = await fetchLhNoticeDetail({
serviceKey: config.molitApiKey,
filters: normalized
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message,
upstream_code: error.upstreamCode || undefined,
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs }
}
};
}
const payload = {
...body,
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/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -2577,6 +2752,94 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/naver-news/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeNaverNewsSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "naver-news-search",
q: normalized.query.toLowerCase(),
display: normalized.display,
start: normalized.start,
sort: normalized.sort
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
let result;
try {
result = await fetchNaverNewsSearch({
...normalized,
clientId: config.naverSearchClientId,
clientSecret: config.naverSearchClientSecret
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
const payload = {
error: error.code || "proxy_error",
message: error.message,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
if (error.upstreamStatusCode) {
payload.upstream = {
status_code: error.upstreamStatusCode,
body_snippet: error.upstreamBodySnippet || null
};
}
return payload;
}
const payload = {
items: result.items,
query: {
q: normalized.query,
display: normalized.display,
start: normalized.start,
sort: normalized.sort
},
meta: result.meta,
upstream: result.upstream,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
async function handleData4LibraryRoute({
request,
reply,
@ -3193,6 +3456,8 @@ module.exports = {
normalizeKmaForecastQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeLhNoticeSearchQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeNeisSchoolMealQuery,

View file

@ -0,0 +1,608 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
LH_DEFAULT_LIST_URL,
LH_DEFAULT_DETAIL_URL,
VALID_PAN_SS_VALUES,
VALID_UPP_AIS_TP_CD_VALUES,
normalizeLhNoticeSearchQuery,
normalizeLhNoticeDetailQuery,
normalizeNoticeItem,
parseXmlErrorEnvelope,
extractNoticeEnvelope,
buildNoticeListResponseBody,
buildNoticeDetailResponseBody,
fetchLhNoticeList,
fetchLhNoticeDetail
} = require("../src/lh-notice");
const SAMPLE_LIST_PAYLOAD = [
{
CMN: {
CODE: "SUCCESS",
ERR_MSG: "",
TOTAL_CNT: 3
}
},
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL:
"https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?ccrCnntSysDsCd=03&panId=2015122300019828&aisTpCd=09&uppAisTpCd=06&mi=1026"
},
{
PAN_ID: "2015122300019816",
PAN_NM: "2026 전세임대형 든든주택 1, 2순위 입주자 모집 공고",
UPP_AIS_TP_CD: "13",
AIS_TP_CD: "17",
AIS_TP_CD_NM: "전세임대",
CNP_CD_NM: "전국",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-08",
SPL_INF_TP_CD: "072",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/lhapply/apply/wt/wrtanc/selectWrtancInfo.do?panId=2015122300019816"
},
{
PAN_ID: "",
PAN_NM: ""
}
]
}
];
const SAMPLE_DETAIL_PAYLOAD = [
{
CMN: {
CODE: "SUCCESS",
ERR_MSG: "",
TOTAL_CNT: 2
}
},
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
HOUSE_TY: "영구임대 29㎡",
SPL_CNT: "120"
},
{
PAN_ID: "2015122300019828",
HOUSE_TY: "영구임대 39㎡",
SPL_CNT: "80"
}
]
}
];
const XML_AUTH_ERROR = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OpenAPI_ServiceResponse>
<cmmMsgHeader>
<errMsg>SERVICE ERROR</errMsg>
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
<returnReasonCode>30</returnReasonCode>
</cmmMsgHeader>
</OpenAPI_ServiceResponse>`;
const STANDARD_JSON_ENVELOPE = {
response: {
header: { resultCode: "00", resultMsg: "NORMAL SERVICE." },
body: {
totalCount: 1,
items: [
{
PAN_ID: "9999",
PAN_NM: "standard envelope sample"
}
]
}
}
};
test("VALID_PAN_SS_VALUES contains the five documented statuses", () => {
for (const status of ["공고중", "접수중", "접수마감", "당첨자발표", "추정공고"]) {
assert.equal(VALID_PAN_SS_VALUES.has(status), true, `missing ${status}`);
}
});
test("VALID_UPP_AIS_TP_CD_VALUES covers the main LH categories", () => {
for (const code of ["01", "05", "06", "13", "22"]) {
assert.equal(VALID_UPP_AIS_TP_CD_VALUES.has(code), true, `missing ${code}`);
}
});
test("normalizeLhNoticeSearchQuery accepts empty input and applies defaults", () => {
const normalized = normalizeLhNoticeSearchQuery({});
assert.equal(normalized.page, 1);
assert.equal(normalized.pageSize, 50);
assert.equal(normalized.panSs, undefined);
assert.equal(normalized.uppAisTpCd, undefined);
assert.equal(normalized.cnpCdNm, undefined);
});
test("normalizeLhNoticeSearchQuery accepts camelCase, snake_case, and short aliases", () => {
const normalized = normalizeLhNoticeSearchQuery({
status: "공고중",
category: "06",
region: "서울특별시",
keyword: "행복주택",
startDate: "2026-01-01",
endDate: "2026.12.31",
page: "2",
limit: "10"
});
assert.equal(normalized.panSs, "공고중");
assert.equal(normalized.uppAisTpCd, "06");
assert.equal(normalized.cnpCdNm, "서울특별시");
assert.equal(normalized.panNm, "행복주택");
assert.equal(normalized.panNtStDt, "2026-01-01");
assert.equal(normalized.clsgDt, "2026-12-31");
assert.equal(normalized.page, 2);
assert.equal(normalized.pageSize, 10);
});
test("normalizeLhNoticeSearchQuery rejects invalid panSs", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ status: "대기중" }),
/panSs must be one of/
);
});
test("normalizeLhNoticeSearchQuery rejects invalid uppAisTpCd", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ category: "77" }),
/uppAisTpCd must be one of/
);
});
test("normalizeLhNoticeSearchQuery rejects invalid aisTpCd (letters)", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ aisTpCd: "abc" }),
/aisTpCd must be digits/
);
});
test("normalizeLhNoticeSearchQuery clamps page/pageSize to documented bounds", () => {
const high = normalizeLhNoticeSearchQuery({ page: "99999", pageSize: "99999" });
assert.equal(high.page, 1000);
assert.equal(high.pageSize, 1000);
const low = normalizeLhNoticeSearchQuery({ page: "0", pageSize: "0" });
assert.equal(low.page, 1);
assert.equal(low.pageSize, 1);
});
test("normalizeLhNoticeSearchQuery accepts YYYYMMDD and YYYY.MM.DD", () => {
const a = normalizeLhNoticeSearchQuery({ startDate: "20260101" });
assert.equal(a.panNtStDt, "2026-01-01");
const b = normalizeLhNoticeSearchQuery({ startDate: "2026.01.02" });
assert.equal(b.panNtStDt, "2026-01-02");
});
test("normalizeLhNoticeSearchQuery rejects malformed dates", () => {
assert.throws(
() => normalizeLhNoticeSearchQuery({ startDate: "2026" }),
/panNtStDt as YYYY-MM-DD/
);
});
test("normalizeLhNoticeDetailQuery requires all three codes", () => {
assert.throws(
() => normalizeLhNoticeDetailQuery({}),
/Provide panId/
);
assert.throws(
() => normalizeLhNoticeDetailQuery({ panId: "2015122300019828" }),
/Provide ccrCnntSysDsCd/
);
assert.throws(
() =>
normalizeLhNoticeDetailQuery({
panId: "2015122300019828",
ccrCnntSysDsCd: "03"
}),
/Provide splInfTpCd/
);
});
test("normalizeLhNoticeDetailQuery accepts the production payload", () => {
const normalized = normalizeLhNoticeDetailQuery({
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
assert.deepEqual(normalized, {
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
});
test("normalizeLhNoticeDetailQuery rejects non-numeric panId", () => {
assert.throws(
() =>
normalizeLhNoticeDetailQuery({
panId: "abc123",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
}),
/panId must be digits/
);
});
test("normalizeNoticeItem maps upstream SCREAMING_SNAKE_CASE keys to snake_case", () => {
const result = normalizeNoticeItem({
PAN_ID: "2015122300019828",
PAN_NM: "샘플 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/..."
});
assert.equal(result.pan_id, "2015122300019828");
assert.equal(result.pan_nm, "샘플 공고");
assert.equal(result.upp_ais_tp_cd, "06");
assert.equal(result.ais_tp_cd_nm, "영구임대");
assert.equal(result.cnp_cd_nm, "부산광역시");
assert.equal(result.pan_ss, "공고중");
assert.equal(result.pan_dt, "2026-04-21");
assert.equal(result.clsg_dt, "2026-05-06");
assert.equal(result.spl_inf_tp_cd, "051");
assert.equal(result.ccr_cnnt_sys_ds_cd, "03");
assert.equal(result.detail_url, "https://apply.lh.or.kr/...");
assert.ok(result.raw, "raw pass-through must be kept");
});
test("normalizeNoticeItem drops rows with no panId AND no panNm", () => {
assert.equal(normalizeNoticeItem({}), null);
assert.equal(normalizeNoticeItem({ PAN_ID: "" }), null);
assert.equal(normalizeNoticeItem({ PAN_NM: " " }), null);
});
test("normalizeNoticeItem keeps rows that have panId only", () => {
const result = normalizeNoticeItem({ PAN_ID: "123" });
assert.equal(result.pan_id, "123");
assert.equal(result.pan_nm, null);
});
test("parseXmlErrorEnvelope pulls code+msg from the OpenAPI_ServiceResponse form", () => {
const err = parseXmlErrorEnvelope(XML_AUTH_ERROR);
assert.ok(err);
assert.equal(err.code, "30");
assert.match(err.message, /SERVICE_KEY/);
});
test("parseXmlErrorEnvelope returns null for non-XML payloads", () => {
assert.equal(parseXmlErrorEnvelope(""), null);
assert.equal(parseXmlErrorEnvelope("Unauthorized"), null);
assert.equal(parseXmlErrorEnvelope('{"a":1}'), null);
});
test("extractNoticeEnvelope handles the LH [CMN,dsList] array envelope", () => {
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
assert.equal(envelope.totalCount, 3);
assert.equal(envelope.items.length, 3);
assert.equal(envelope.items[0].PAN_ID, "2015122300019828");
});
test("extractNoticeEnvelope throws an error for CMN.CODE != SUCCESS", () => {
const payload = [
{ CMN: { CODE: "FAIL", ERR_MSG: "Service key invalid", TOTAL_CNT: 0 } },
{ dsList: [] }
];
assert.throws(() => extractNoticeEnvelope(payload), /Service key invalid/);
});
// Regression: the LH catalog has historically surfaced `CMN.CODE` as one of
// `"SUCCESS"`, `"0"`, `"00"`, or `"000"` depending on which era of the
// data.go.kr platform the upstream deploy is on. All four MUST be treated as
// success so that we do not erroneously 502 when data.go.kr normalizes the
// array envelope to the standard numeric code. See the inline comment above
// `extractNoticeEnvelope`'s success-code check for the historical context.
for (const successCode of ["SUCCESS", "0", "00", "000"]) {
test(`extractNoticeEnvelope treats array-envelope CMN.CODE="${successCode}" as success`, () => {
const payload = [
{ CMN: { CODE: successCode, ERR_MSG: "", TOTAL_CNT: 1 } },
{
dsList: [
{
PAN_ID: "1234567890",
PAN_NM: "success-code regression fixture",
UPP_AIS_TP_CD: "06"
}
]
}
];
const envelope = extractNoticeEnvelope(payload);
assert.equal(envelope.totalCount, 1);
assert.equal(envelope.items.length, 1);
assert.equal(envelope.items[0].PAN_ID, "1234567890");
});
}
// Same regression for the standard data.go.kr `{response:{header,body:{items}}}`
// envelope. Upstream has been seen returning `resultCode` in all three
// numeric forms; XML-based services also tolerate `"0"` despite the catalog
// documenting `"00"` for `B552555`. All three MUST be treated as success so
// that the fallback parser does not erroneously flag a valid response as an
// upstream error.
for (const headerCode of ["0", "00", "000"]) {
test(`extractNoticeEnvelope treats object-envelope header.resultCode="${headerCode}" as success`, () => {
const payload = {
response: {
header: { resultCode: headerCode, resultMsg: "NORMAL SERVICE." },
body: {
totalCount: 1,
items: [
{
PAN_ID: "9999",
PAN_NM: "object-envelope regression fixture"
}
]
}
}
};
const envelope = extractNoticeEnvelope(payload);
assert.equal(envelope.totalCount, 1);
assert.equal(envelope.items.length, 1);
assert.equal(envelope.items[0].PAN_ID, "9999");
});
}
test("extractNoticeEnvelope rejects numeric-like non-success codes (e.g. 22, 10)", () => {
// Not every `resultCode` that LOOKS numeric is success — data.go.kr uses
// non-zero integer strings like "22", "10", "30" to signal specific upstream
// errors (LIMITED_NUMBER_OF_SERVICE_REQUESTS_EXCEEDS_ERROR, etc.).
const arrayPayload = [
{ CMN: { CODE: "22", ERR_MSG: "LIMITED NUMBER OF SERVICE REQUESTS EXCEEDS ERROR", TOTAL_CNT: 0 } },
{ dsList: [] }
];
assert.throws(
() => extractNoticeEnvelope(arrayPayload),
/LIMITED NUMBER OF SERVICE REQUESTS EXCEEDS ERROR/
);
const objectPayload = {
response: {
header: { resultCode: "10", resultMsg: "APPLICATION ERROR" },
body: { items: [] }
}
};
assert.throws(() => extractNoticeEnvelope(objectPayload), /APPLICATION ERROR/);
});
test("extractNoticeEnvelope also handles the standard data.go.kr response/body shape", () => {
const envelope = extractNoticeEnvelope(STANDARD_JSON_ENVELOPE);
assert.equal(envelope.totalCount, 1);
assert.equal(envelope.items.length, 1);
assert.equal(envelope.items[0].PAN_ID, "9999");
});
test("extractNoticeEnvelope returns empty list for unknown shapes", () => {
assert.deepEqual(extractNoticeEnvelope(null), { totalCount: null, items: [] });
assert.deepEqual(extractNoticeEnvelope({ foo: "bar" }), { totalCount: null, items: [] });
});
test("buildNoticeListResponseBody produces the proxy-facing JSON shape", () => {
const envelope = extractNoticeEnvelope(SAMPLE_LIST_PAYLOAD);
const body = buildNoticeListResponseBody(envelope, {
page: 1,
pageSize: 50,
filters: { pan_ss: "공고중" }
});
assert.equal(body.items.length, 2, "empty row must be skipped");
assert.equal(body.summary.page, 1);
assert.equal(body.summary.page_size, 50);
assert.equal(body.summary.returned_count, 2);
assert.equal(body.summary.total_count, 3);
assert.equal(body.query.pan_ss, "공고중");
assert.equal(body.items[0].pan_id, "2015122300019828");
});
test("buildNoticeDetailResponseBody returns notice + supply_infos", () => {
const envelope = extractNoticeEnvelope(SAMPLE_DETAIL_PAYLOAD);
const body = buildNoticeDetailResponseBody(envelope, {
panId: "2015122300019828",
ccrCnntSysDsCd: "03",
splInfTpCd: "051"
});
assert.equal(body.notice.pan_id, "2015122300019828");
assert.equal(body.notice.ais_tp_cd_nm, "영구임대");
assert.equal(body.supply_infos.length, 2);
assert.equal(body.supply_infos[0].HOUSE_TY, "영구임대 29㎡");
assert.equal(body.query.panId, "2015122300019828");
});
test("fetchLhNoticeList builds the expected data.go.kr URL and returns parsed items", async () => {
const calls = [];
const mockFetch = async (url) => {
calls.push(url);
return {
ok: true,
status: 200,
headers: { get: () => "application/json;charset=UTF-8" },
text: async () => JSON.stringify(SAMPLE_LIST_PAYLOAD)
};
};
const body = await fetchLhNoticeList({
serviceKey: "test-key",
filters: {
page: 2,
pageSize: 25,
panSs: "공고중",
uppAisTpCd: "06",
aisTpCd: "09",
cnpCdNm: "부산광역시",
panNm: "영구임대",
panNtStDt: "2026-04-01",
clsgDt: "2026-05-31"
},
fetchImpl: mockFetch
});
assert.equal(calls.length, 1);
const requested = calls[0];
assert.ok(requested.startsWith(LH_DEFAULT_LIST_URL), `URL must hit list endpoint: ${requested}`);
assert.match(requested, /PG_SZ=25/);
assert.match(requested, /PAGE=2/);
assert.match(requested, /PAN_SS=%EA%B3%B5%EA%B3%A0%EC%A4%91/);
assert.match(requested, /UPP_AIS_TP_CD=06/);
assert.match(requested, /AIS_TP_CD=09/);
assert.match(requested, /CNP_CD_NM=%EB%B6%80%EC%82%B0/);
assert.match(requested, /PAN_NT_ST_DT=2026-04-01/);
assert.match(requested, /CLSG_DT=2026-05-31/);
assert.match(requested, /serviceKey=test-key/);
assert.equal(body.items.length, 2);
assert.equal(body.summary.page, 2);
assert.equal(body.summary.page_size, 25);
assert.equal(body.summary.total_count, 3);
});
test("fetchLhNoticeList surfaces upstream 401 as upstream_not_authorized (statusCode 503)", async () => {
const mockFetch = async () => ({
ok: false,
status: 401,
headers: { get: () => "text/plain" },
text: async () => "Unauthorized"
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "bad",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_not_authorized");
assert.equal(err.statusCode, 503);
return true;
}
);
});
test("fetchLhNoticeList surfaces XML SERVICE_KEY error envelopes with upstreamCode", async () => {
const mockFetch = async () => ({
ok: true,
status: 200,
headers: { get: () => "text/xml" },
text: async () => XML_AUTH_ERROR
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "bad",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_error");
assert.equal(err.statusCode, 502);
assert.equal(err.upstreamCode, "30");
assert.match(err.message, /SERVICE_KEY/);
return true;
}
);
});
test("fetchLhNoticeList surfaces non-JSON payloads as upstream_invalid_payload", async () => {
const mockFetch = async () => ({
ok: true,
status: 200,
headers: { get: () => "text/html" },
text: async () => "<html>not json</html>"
});
await assert.rejects(
fetchLhNoticeList({
serviceKey: "x",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_invalid_payload");
assert.equal(err.statusCode, 502);
return true;
}
);
});
test("fetchLhNoticeList surfaces fetch failures as upstream_fetch_failed", async () => {
const mockFetch = async () => {
throw new Error("socket hang up");
};
await assert.rejects(
fetchLhNoticeList({
serviceKey: "x",
filters: { page: 1, pageSize: 50 },
fetchImpl: mockFetch
}),
(err) => {
assert.equal(err.code, "upstream_fetch_failed");
assert.equal(err.statusCode, 502);
return true;
}
);
});
test("fetchLhNoticeDetail calls the detail endpoint with PAN_ID + SPL_INF_TP_CD + CCR_CNNT_SYS_DS_CD", async () => {
const calls = [];
const mockFetch = async (url) => {
calls.push(url);
return {
ok: true,
status: 200,
headers: { get: () => "application/json" },
text: async () => JSON.stringify(SAMPLE_DETAIL_PAYLOAD)
};
};
const body = await fetchLhNoticeDetail({
serviceKey: "test-key",
filters: { panId: "2015122300019828", ccrCnntSysDsCd: "03", splInfTpCd: "051" },
fetchImpl: mockFetch
});
assert.equal(calls.length, 1);
const requested = calls[0];
assert.ok(requested.startsWith(LH_DEFAULT_DETAIL_URL), `URL must hit detail endpoint: ${requested}`);
assert.match(requested, /PAN_ID=2015122300019828/);
assert.match(requested, /CCR_CNNT_SYS_DS_CD=03/);
assert.match(requested, /SPL_INF_TP_CD=051/);
assert.equal(body.notice.pan_id, "2015122300019828");
assert.equal(body.supply_infos.length, 2);
});

View file

@ -0,0 +1,687 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildNaverNewsSearchUrl,
normalizeNaverNewsSearchQuery,
normalizeNaverNewsSearchPayload
} = require("../src/naver-news");
const { buildServer } = require("../src/server");
test("normalizeNaverNewsSearchQuery validates q/query and clamps display/start/sort", () => {
assert.throws(() => normalizeNaverNewsSearchQuery({}), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "" }), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: " " }), /Provide q\/query/);
assert.throws(() => normalizeNaverNewsSearchQuery({ q: "a" }), /at least 2/);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ query: " 인공지능 ", display: "999", start: "1", sort: "date" }),
{
query: "인공지능",
display: 100,
start: 1,
sort: "date"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ query: "주식", display: "1", start: "9999", sort: "date" }),
{
query: "주식",
display: 1,
start: 1000,
sort: "date"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "삼성전자" }),
{
query: "삼성전자",
display: 10,
start: 1,
sort: "sim"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "정부", display: "0", start: "0", sort: "UNKNOWN" }),
{
query: "정부",
display: 1,
start: 1,
sort: "sim"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "대한민국", display: "-5", start: "-1" }),
{
query: "대한민국",
display: 1,
start: 1,
sort: "sim"
}
);
});
test("normalizeNaverNewsSearchQuery accepts keyword as an alias for q/query", () => {
assert.deepEqual(
normalizeNaverNewsSearchQuery({ keyword: "스타트업" }),
{
query: "스타트업",
display: 10,
start: 1,
sort: "sim"
}
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ keyword: "반도체", display: "20", start: "5", sort: "date" }),
{
query: "반도체",
display: 20,
start: 5,
sort: "date"
}
);
});
test("normalizeNaverNewsSearchQuery rejects start+display combinations exceeding Naver's 1000-item window", () => {
// Boundary values that are still valid (start + display - 1 === 1000 or below) must pass.
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "경계", start: "1000", display: "1" }),
{ query: "경계", display: 1, start: 1000, sort: "sim" }
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "경계", start: "901", display: "100" }),
{ query: "경계", display: 100, start: 901, sort: "sim" }
);
assert.deepEqual(
normalizeNaverNewsSearchQuery({ q: "경계", start: "500", display: "100" }),
{ query: "경계", display: 100, start: 500, sort: "sim" }
);
// One past the boundary (start + display - 1 > 1000) must throw preflight 400.
assert.throws(
() => normalizeNaverNewsSearchQuery({ q: "초과", start: "902", display: "100" }),
/1000-item|window|Naver/i
);
assert.throws(
() => normalizeNaverNewsSearchQuery({ q: "초과", start: "1000", display: "2" }),
/1000-item|window|Naver/i
);
assert.throws(
() => normalizeNaverNewsSearchQuery({ q: "초과", start: "1000", display: "100" }),
/1000-item|window|Naver/i
);
assert.throws(
() => normalizeNaverNewsSearchQuery({ q: "초과", start: "950", display: "60" }),
/1000-item|window|Naver/i
);
});
test("buildNaverNewsSearchUrl constructs the official Naver Search news endpoint URL", () => {
const url = buildNaverNewsSearchUrl({
query: "인공지능",
display: 10,
start: 1,
sort: "sim"
});
assert.equal(url.hostname, "openapi.naver.com");
assert.equal(url.pathname, "/v1/search/news.json");
assert.equal(url.searchParams.get("query"), "인공지능");
assert.equal(url.searchParams.get("display"), "10");
assert.equal(url.searchParams.get("start"), "1");
assert.equal(url.searchParams.get("sort"), "sim");
const dateUrl = buildNaverNewsSearchUrl({
query: "삼성전자",
display: 30,
start: 20,
sort: "date"
});
assert.equal(dateUrl.searchParams.get("sort"), "date");
assert.equal(dateUrl.searchParams.get("display"), "30");
assert.equal(dateUrl.searchParams.get("start"), "20");
});
test("normalizeNaverNewsSearchPayload maps Naver API items, strips <b> tags and decodes entities", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 26 Sep 2016 11:01:35 +0900",
total: 2566589,
start: 1,
display: 2,
items: [
{
title: "국내 <b>주식</b>형펀드서 사흘째 자금 순유출",
originallink: "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008",
link: "http://openapi.naver.com/l?AAAC2NSw",
description: "국내 <b>주식</b>형 펀드에서 사흘째 자금이 &quot;빠져나갔다&quot;. 26일 금융투자협회에 따르면 지난 22일 상장지수펀드(ETF)를 제외한 국내 <b>주식</b>형 펀드에서 126억원이 순유출...",
pubDate: "Mon, 26 Sep 2016 07:50:00 +0900"
},
{
title: "두 번째 &amp; <b>기사</b> 제목",
originallink: "",
link: "https://news.naver.com/main/read.nhn?oid=001&aid=000",
description: "두 번째 기사 본문 요약...",
pubDate: "Mon, 26 Sep 2016 07:00:00 +0900"
}
]
},
{ query: "주식", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 2);
const first = result.items[0];
assert.equal(first.rank, 1);
assert.equal(first.title, "국내 주식형펀드서 사흘째 자금 순유출");
assert.equal(first.original_link, "http://app.yonhapnews.co.kr/YNA/Basic/SNS/r.aspx?c=AKR20160926019000008");
assert.equal(first.link, "http://openapi.naver.com/l?AAAC2NSw");
assert.match(first.description, /국내 주식형 펀드에서 사흘째 자금이 "빠져나갔다"/);
assert.doesNotMatch(first.description, /<b>/);
assert.equal(first.pub_date, "Mon, 26 Sep 2016 07:50:00 +0900");
assert.equal(first.pub_date_iso, "2016-09-25T22:50:00.000Z");
assert.equal(first.source, "naver-openapi");
const second = result.items[1];
assert.equal(second.rank, 2);
assert.equal(second.title, "두 번째 & 기사 제목");
assert.equal(second.original_link, null);
assert.equal(second.link, "https://news.naver.com/main/read.nhn?oid=001&aid=000");
assert.equal(result.meta.query, "주식");
assert.equal(result.meta.extraction, "naver-openapi");
assert.equal(result.meta.item_count, 2);
assert.equal(result.meta.total, 2566589);
assert.equal(result.meta.start, 1);
assert.equal(result.meta.display, 2);
assert.equal(result.meta.last_build_date, "Mon, 26 Sep 2016 11:01:35 +0900");
assert.equal(result.meta.sort, "sim");
});
test("normalizeNaverNewsSearchPayload skips items without title or link and deduplicates by link", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 3,
start: 1,
display: 3,
items: [
{
title: "정상 기사",
originallink: "https://news.example.com/1",
link: "https://n.news.naver.com/mnews/article/1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "",
originallink: "https://news.example.com/2",
link: "https://n.news.naver.com/mnews/article/2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "중복 link 기사",
originallink: "https://news.example.com/dupe",
link: "https://n.news.naver.com/mnews/article/1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
}
]
},
{ query: "테스트", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].title, "정상 기사");
});
test("normalizeNaverNewsSearchPayload dedupes links that differ only in query-param order, trailing slash, or host casing", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 5,
start: 1,
display: 5,
items: [
{
title: "원본 기사",
originallink: "https://publisher.example.com/42",
link: "https://news.example.com/articles/42?a=1&b=2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "쿼리 파라미터 순서만 다른 중복",
originallink: "https://publisher.example.com/42-b",
link: "https://news.example.com/articles/42?b=2&a=1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "trailing slash 만 다른 중복",
originallink: "https://publisher.example.com/42-c",
link: "https://news.example.com/articles/42/?a=1&b=2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "host 대소문자·fragment 만 다른 중복",
originallink: "https://publisher.example.com/42-d",
link: "https://NEWS.example.com/articles/42?a=1&b=2#comments",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "진짜 다른 기사",
originallink: "https://publisher.example.com/43",
link: "https://news.example.com/articles/43?a=1&b=2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
}
]
},
{ query: "중복", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 2);
assert.equal(result.items[0].title, "원본 기사");
assert.equal(result.items[1].title, "진짜 다른 기사");
});
test("normalizeNaverNewsSearchPayload preserves items that differ by path or by a non-redundant query param", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 3,
start: 1,
display: 3,
items: [
{
title: "첫 번째",
originallink: "https://publisher.example.com/1",
link: "https://news.example.com/articles/42?a=1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "다른 쿼리 값",
originallink: "https://publisher.example.com/2",
link: "https://news.example.com/articles/42?a=2",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
},
{
title: "다른 경로",
originallink: "https://publisher.example.com/3",
link: "https://news.example.com/articles/42/related?a=1",
description: "본문",
pubDate: "Mon, 22 Apr 2026 00:00:00 +0900"
}
]
},
{ query: "서로다른", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 3);
assert.equal(result.items[0].title, "첫 번째");
assert.equal(result.items[1].title, "다른 쿼리 값");
assert.equal(result.items[2].title, "다른 경로");
});
test("normalizeNaverNewsSearchPayload handles missing optional fields gracefully", () => {
const result = normalizeNaverNewsSearchPayload(
{
lastBuildDate: "Mon, 22 Apr 2026 00:00:00 +0900",
total: 1,
start: 1,
display: 1,
items: [
{
title: "간단 기사",
originallink: "https://news.example.com/1",
link: "https://news.example.com/1",
description: "",
pubDate: "invalid-date-string"
}
]
},
{ query: "테스트", display: 10, start: 1, sort: "sim" }
);
assert.equal(result.items.length, 1);
assert.equal(result.items[0].description, null);
assert.equal(result.items[0].pub_date, "invalid-date-string");
assert.equal(result.items[0].pub_date_iso, null);
});
test("naver news search endpoint returns 400 when query is missing", async (t) => {
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({ method: "GET", url: "/v1/naver-news/search" });
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("naver news search endpoint returns 503 when proxy credentials are missing", async (t) => {
const app = buildServer({ env: { KSKILL_PROXY_CACHE_TTL_MS: "60000" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90"
});
assert.equal(response.statusCode, 503);
const body = response.json();
assert.equal(body.error, "upstream_not_configured");
assert.match(body.message, /NAVER_SEARCH_CLIENT_ID/);
});
test("naver news search endpoint returns 400 preflight when start+display exceeds Naver's 1000-item window", async (t) => {
const originalFetch = global.fetch;
let fetchCount = 0;
global.fetch = async () => {
fetchCount += 1;
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&start=1000&display=100"
});
assert.equal(response.statusCode, 400);
const body = response.json();
assert.equal(body.error, "bad_request");
assert.match(body.message, /1000-item|window|Naver/i);
assert.equal(fetchCount, 0, "must not call upstream when preflight fails");
});
test("naver news search endpoint proxies to official API with correct headers and params", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
total: 1234567,
start: 1,
display: 2,
items: [
{
title: "<b>삼성전자</b> 1분기 실적 발표",
originallink: "https://news.example.com/samsung",
link: "https://n.news.naver.com/mnews/article/samsung",
description: "<b>삼성전자</b>가 올해 1분기 실적을 발표했다.",
pubDate: "Mon, 22 Apr 2026 09:30:00 +0900"
},
{
title: "두 번째 기사",
originallink: "https://news.example.com/second",
link: "https://n.news.naver.com/mnews/article/second",
description: "두 번째 기사 요약",
pubDate: "Mon, 22 Apr 2026 08:00:00 +0900"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "test-client-id",
NAVER_SEARCH_CLIENT_SECRET: "test-client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&display=5&sort=date"
});
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.query.q, "삼성전자");
assert.equal(body.query.display, 5);
assert.equal(body.query.sort, "date");
assert.equal(body.items.length, 2);
assert.equal(body.items[0].title, "삼성전자 1분기 실적 발표");
assert.equal(body.items[0].description, "삼성전자가 올해 1분기 실적을 발표했다.");
assert.equal(body.items[0].source, "naver-openapi");
assert.equal(body.meta.total, 1234567);
assert.equal(body.meta.extraction, "naver-openapi");
assert.equal(body.upstream.provider, "naver-search-api");
assert.equal(body.proxy.cache.hit, false);
assert.equal(fetchCalls.length, 1);
const upstreamUrl = new URL(fetchCalls[0].url);
assert.equal(upstreamUrl.hostname, "openapi.naver.com");
assert.equal(upstreamUrl.pathname, "/v1/search/news.json");
assert.equal(upstreamUrl.searchParams.get("query"), "삼성전자");
assert.equal(upstreamUrl.searchParams.get("display"), "5");
assert.equal(upstreamUrl.searchParams.get("sort"), "date");
assert.equal(fetchCalls[0].headers["X-Naver-Client-Id"], "test-client-id");
assert.equal(fetchCalls[0].headers["X-Naver-Client-Secret"], "test-client-secret");
});
test("naver news search endpoint caches successful responses and serves hit on second call", async (t) => {
const originalFetch = global.fetch;
let fetchCount = 0;
global.fetch = async () => {
fetchCount += 1;
return new Response(
JSON.stringify({
lastBuildDate: "Mon, 22 Apr 2026 10:00:00 +0900",
total: 1,
start: 1,
display: 1,
items: [
{
title: "캐시 테스트 기사",
originallink: "https://news.example.com/cache",
link: "https://n.news.naver.com/mnews/article/cache",
description: "본문",
pubDate: "Mon, 22 Apr 2026 09:00:00 +0900"
}
]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const firstResponse = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
});
assert.equal(firstResponse.statusCode, 200);
assert.equal(firstResponse.json().proxy.cache.hit, false);
const secondResponse = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%BA%90%EC%8B%9C"
});
assert.equal(secondResponse.statusCode, 200);
assert.equal(secondResponse.json().proxy.cache.hit, true);
assert.equal(fetchCount, 1);
});
test("naver news search endpoint surfaces upstream errors without caching them", async (t) => {
const originalFetch = global.fetch;
let fetchCount = 0;
global.fetch = async () => {
fetchCount += 1;
return new Response(
JSON.stringify({
errorMessage: "Authentication failed",
errorCode: "024"
}),
{ status: 401, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "bad-id",
NAVER_SEARCH_CLIENT_SECRET: "bad-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
});
assert.equal(response.statusCode, 401);
assert.equal(response.json().error, "upstream_error");
assert.equal(response.json().upstream.status_code, 401);
const retry = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EB%B0%B0%EB%93%A0"
});
assert.equal(retry.statusCode, 401);
assert.equal(fetchCount, 2, "failures must not be cached");
});
test("naver news search endpoint surfaces upstream 429 rate-limit and echoes status code", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response(
JSON.stringify({
errorMessage: "Request limit exceeded",
errorCode: "010"
}),
{ status: 429, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%A0%9C%ED%95%9C"
});
assert.equal(response.statusCode, 429);
assert.equal(response.json().error, "upstream_error");
});
test("naver news search endpoint reports 5xx upstream failures as 502 proxy error", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => {
return new Response("Internal Server Error", {
status: 500,
headers: { "content-type": "text/plain" }
});
};
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/naver-news/search?q=%EC%9E%A5%EC%95%A0"
});
assert.equal(response.statusCode, 502);
assert.equal(response.json().error, "upstream_error");
assert.equal(response.json().upstream.status_code, 500);
});
test("health endpoint exposes naverNewsApiConfigured flag based on credentials", async (t) => {
const app = buildServer({
env: {
NAVER_SEARCH_CLIENT_ID: "client-id",
NAVER_SEARCH_CLIENT_SECRET: "client-secret"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({ method: "GET", url: "/health" });
assert.equal(response.statusCode, 200);
const body = response.json();
assert.equal(body.upstreams.naverNewsApiConfigured, true);
const appNoKeys = buildServer({ env: {} });
const healthNoKeys = await appNoKeys.inject({ method: "GET", url: "/health" });
assert.equal(healthNoKeys.json().upstreams.naverNewsApiConfigured, false);
await appNoKeys.close();
});

View file

@ -3685,3 +3685,316 @@ test("parking lot search endpoint reports missing Data.go.kr key", async (t) =>
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});
test("lh-notice search endpoint stays public and caches normalized queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify([
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 2 } },
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "2026년 상반기 부산광역시 영구임대주택 예비입주자 모집 공고",
UPP_AIS_TP_CD: "06",
AIS_TP_CD: "09",
AIS_TP_CD_NM: "영구임대",
CNP_CD_NM: "부산광역시",
PAN_SS: "공고중",
PAN_DT: "2026-04-21",
CLSG_DT: "2026-05-06",
SPL_INF_TP_CD: "051",
CCR_CNNT_SYS_DS_CD: "03",
DTL_URL: "https://apply.lh.or.kr/detail?panId=2015122300019828"
},
{
PAN_ID: "2015122300019816",
PAN_NM: "2026 전세임대형 든든주택 모집 공고",
UPP_AIS_TP_CD: "13",
AIS_TP_CD: "17",
AIS_TP_CD_NM: "전세임대",
CNP_CD_NM: "전국",
PAN_SS: "공고중"
}
]
}
]),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: {
DATA_GO_KR_API_KEY: "data-go-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/lh-notice/search?panSs=%EA%B3%B5%EA%B3%A0%EC%A4%91&limit=10"
});
const firstBody = first.json();
assert.equal(first.statusCode, 200);
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.items.length, 2);
assert.equal(firstBody.items[0].pan_id, "2015122300019828");
assert.equal(firstBody.items[0].ais_tp_cd_nm, "영구임대");
assert.equal(firstBody.summary.returned_count, 2);
assert.equal(firstBody.summary.total_count, 2);
assert.equal(firstBody.query.pan_ss, "공고중");
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0], /PG_SZ=10/);
assert.match(fetchCalls[0], /PAGE=1/);
assert.match(fetchCalls[0], /serviceKey=data-go-key/);
const second = await app.inject({
method: "GET",
url: "/v1/lh-notice/search?status=%EA%B3%B5%EA%B3%A0%EC%A4%91&pageSize=10"
});
const secondBody = second.json();
assert.equal(second.statusCode, 200);
assert.equal(secondBody.proxy.cache.hit, true, "alias-normalized second call must reuse cache");
assert.equal(fetchCalls.length, 1, "cache hit must not hit upstream again");
});
test("lh-notice search returns 400 for unsupported panSs", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/lh-notice/search?panSs=%EB%8C%80%EA%B8%B0%EC%A4%91"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.match(response.json().message, /panSs must be one of/);
});
test("lh-notice search returns 503 when DATA_GO_KR_API_KEY is missing", async (t) => {
const app = buildServer();
t.after(async () => { await app.close(); });
const response = await app.inject({
method: "GET",
url: "/v1/lh-notice/search"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
assert.match(response.json().message, /DATA_GO_KR_API_KEY/);
});
test("lh-notice search does not cache upstream XML auth errors so retries self-heal", async (t) => {
const originalFetch = global.fetch;
const xmlError = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OpenAPI_ServiceResponse>
<cmmMsgHeader>
<errMsg>SERVICE ERROR</errMsg>
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
<returnReasonCode>30</returnReasonCode>
</cmmMsgHeader>
</OpenAPI_ServiceResponse>`;
let mode = "fail";
const successPayload = [
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
{ dsList: [{ PAN_ID: "111", PAN_NM: "recovered notice" }] }
];
global.fetch = async () => {
if (mode === "fail") {
return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } });
}
return new Response(JSON.stringify(successPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const first = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_error");
assert.equal(first.json().upstream_code, "30");
mode = "ok";
const second = await app.inject({ method: "GET", url: "/v1/lh-notice/search" });
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, false, "failure must not have been cached");
assert.equal(second.json().items[0].pan_id, "111");
});
test("lh-notice detail endpoint requires all three codes and caches successful lookups", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url) => {
fetchCalls.push(String(url));
return new Response(
JSON.stringify([
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "영구임대 예비입주자 모집",
UPP_AIS_TP_CD: "06",
AIS_TP_CD_NM: "영구임대",
HOUSE_TY: "29㎡",
SPL_CNT: "120"
}
]
}
]),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const missing = await app.inject({ method: "GET", url: "/v1/lh-notice/detail" });
assert.equal(missing.statusCode, 400);
assert.match(missing.json().message, /Provide panId/);
const first = await app.inject({
method: "GET",
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
});
assert.equal(first.statusCode, 200);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(first.json().notice.pan_id, "2015122300019828");
assert.equal(first.json().notice.ais_tp_cd_nm, "영구임대");
assert.equal(first.json().supply_infos.length, 1);
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0], /lhLeaseNoticeDtlInfo1\/getLeaseNoticeDtlInfo1/);
assert.match(fetchCalls[0], /PAN_ID=2015122300019828/);
assert.match(fetchCalls[0], /CCR_CNNT_SYS_DS_CD=03/);
assert.match(fetchCalls[0], /SPL_INF_TP_CD=051/);
const second = await app.inject({
method: "GET",
url: "/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051"
});
assert.equal(second.statusCode, 200);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls.length, 1, "cached detail must not retrigger upstream");
});
// Pins the /v1/lh-notice/detail failure-not-cached contract against BOTH
// cache-protection layers:
// (a) the early-return catch block in the route handler (no `cache.set`
// is reached on upstream failure; see src/server.js lh-notice/detail
// route), and
// (b) the `isFailureResponse()` guard inside `cache.set` (refuses any
// payload with `.error` set; see src/server.js createMemoryCache).
// Bypassing either layer alone makes the State 2 self-heal assertion
// ("detail failure must not have been cached — retry must hit upstream")
// fail — proven by independent sabotage audit in PR #158 Round 3 review.
// See the sibling /search failure-not-cached test above for symmetric
// coverage across the two lh-notice routes.
test("lh-notice detail does not cache upstream XML auth errors so retries self-heal", async (t) => {
const originalFetch = global.fetch;
const xmlError = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<OpenAPI_ServiceResponse>
<cmmMsgHeader>
<errMsg>SERVICE ERROR</errMsg>
<returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg>
<returnReasonCode>30</returnReasonCode>
</cmmMsgHeader>
</OpenAPI_ServiceResponse>`;
let mode = "fail";
const fetchCalls = [];
const successPayload = [
{ CMN: { CODE: "SUCCESS", ERR_MSG: "", TOTAL_CNT: 1 } },
{
dsList: [
{
PAN_ID: "2015122300019828",
PAN_NM: "영구임대 예비입주자 모집 (recovered)",
UPP_AIS_TP_CD: "06",
AIS_TP_CD_NM: "영구임대",
HOUSE_TY: "29㎡",
SPL_CNT: "120"
}
]
}
];
global.fetch = async (url) => {
fetchCalls.push(String(url));
if (mode === "fail") {
return new Response(xmlError, { status: 200, headers: { "content-type": "text/xml" } });
}
return new Response(JSON.stringify(successPayload), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { global.fetch = originalFetch; await app.close(); });
const detailUrl =
"/v1/lh-notice/detail?panId=2015122300019828&ccrCnntSysDsCd=03&splInfTpCd=051";
const first = await app.inject({ method: "GET", url: detailUrl });
assert.equal(first.statusCode, 502);
assert.equal(first.json().error, "upstream_error");
assert.equal(first.json().upstream_code, "30");
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(fetchCalls.length, 1, "first call must hit upstream exactly once");
mode = "ok";
const second = await app.inject({ method: "GET", url: detailUrl });
assert.equal(second.statusCode, 200);
assert.equal(
second.json().proxy.cache.hit,
false,
"detail failure must not have been cached — retry must hit upstream"
);
assert.equal(second.json().notice.pan_id, "2015122300019828");
assert.match(second.json().notice.pan_nm, /recovered/);
assert.equal(
fetchCalls.length,
2,
"second call must hit upstream again (no cache for failure)"
);
mode = "fail";
const third = await app.inject({ method: "GET", url: detailUrl });
assert.equal(
third.statusCode,
200,
"third call must hit cache (success was cached)"
);
assert.equal(third.json().proxy.cache.hit, true);
assert.equal(
fetchCalls.length,
2,
"third call must serve from cache (no new upstream fetch)"
);
});
test("health endpoint reports lhNoticeConfigured when DATA_GO_KR_API_KEY is set", async (t) => {
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => { await app.close(); });
const response = await app.inject({ method: "GET", url: "/health" });
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.lhNoticeConfigured, true);
});

View file

@ -0,0 +1,119 @@
# k-skill-rhwp
Node-side HWP editing CLI that wraps [`@rhwp/core`](https://www.npmjs.com/package/@rhwp/core)
(Rust + WebAssembly, MIT, by Edward Kim) as subcommands.
- **Ships the `k-skill-rhwp` binary** for the `rhwp-edit` skill in
[NomaDamas/k-skill](https://github.com/NomaDamas/k-skill).
- **Round-trip safe HWP 5.x editing** — insert/delete text, replace-all, create
tables, set cell text, and render pages to SVG or HTML.
- **Node 18+ only.** No Rust toolchain required; the shipped WASM does the work.
For debugging the upstream `rhwp` Rust CLI (`export-svg --debug-overlay`,
`dump`, `ir-diff`, `thumbnail`, `convert`), see the `rhwp-advanced` skill —
this package does not wrap those commands.
For `.hwp` → Markdown / JSON / form-field extraction, see the `hwp` skill
(kordoc-based). This package is editing-only.
## Install
```bash
npm install k-skill-rhwp
# or run one-off
npx --yes k-skill-rhwp --help
```
## CLI
```bash
# Metadata / structure
k-skill-rhwp info <input.hwp>
k-skill-rhwp list-paragraphs <input.hwp> [--section N]
k-skill-rhwp search <input.hwp> --query TEXT [--from-section N] [--from-paragraph N] [--from-char N] [--case-sensitive]
# Body editing
k-skill-rhwp insert-text <input> <output> --section N --paragraph N --offset N --text TEXT
k-skill-rhwp delete-text <input> <output> --section N --paragraph N --offset N --count N
k-skill-rhwp replace-all <input> <output> --query TEXT --replacement TEXT [--case-sensitive]
# Tables
k-skill-rhwp create-table <input> <output> --section N --paragraph N --offset N --rows N --cols N
k-skill-rhwp set-cell-text <input> <output> --section N --parent-paragraph N --control N --cell N --text TEXT [--cell-paragraph N] [--no-replace]
# Rendering / creation
k-skill-rhwp create-blank <output.hwp>
k-skill-rhwp render <input.hwp> [--page N] [--format svg|html]
```
Every editing subcommand writes a brand-new HWP file (never overwrites the
input) and prints a JSON summary including `ok`, post-edit cursor position,
`bytesWritten`, and the resolved `outputPath`.
### Scope of `search` and `replace-all`
Both `search` and `replace-all` operate on **body paragraphs only**. Text
inside table cells, headers/footers, or footnotes is not scanned. This
mirrors the upstream `@rhwp/core` `searchText` scope. For cell text, use
`info` or `list-paragraphs` to locate the table and then `set-cell-text` to
write. `replace-all` also rejects any `--replacement` that contains newline
or paragraph-break characters (`\n`, `\r`, U+2028, U+2029) because they would
split a paragraph — split those into multiple `insert-text` calls instead.
`replace-all` uses **non-overlapping** replacement semantics: matches are
computed against the original text before any replacement runs, so
`--query a --replacement aa` against `aaa` replaces 3 originals and yields
`aaaaaa`, not an infinite loop.
Case-insensitive matching (the default) relies on `String.prototype.toLowerCase()`
preserving UTF-16 length so offsets taken in the lowercased haystack still apply
to the original text. A handful of Unicode characters (notably Turkish `İ`
U+0130, which lowercases to `i` + combining dot above U+0307) violate that
invariant. When either the query or a paragraph contains such a character,
`replace-all` refuses the operation with exit code 1 and a `case-insensitive
matching is unsafe because case folding changes the UTF-16 length` message
rather than silently drifting every subsequent offset. Rerun with
`--case-sensitive`, or normalize the input. ASCII, Hangul, and the common HWP
use cases (e.g. `2025 → 2026`) are not affected.
## Node API
```js
const { insertText, createTable, setCellText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./draft.hwp",
output: "./draft-with-title.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 신청서"
});
console.log(await getDocumentInfo("./draft-with-title.hwp"));
```
The first call loads `@rhwp/core` WASM once per process. The WASM requires a
`globalThis.measureTextWidth(font, text)` callback for text layout; this
package auto-installs a deterministic approximation shim on first use so it
works headless on Node without `canvas`. Replace the shim before the first
call if you need pixel-accurate metrics.
## Known limitations
- **HWPX round-trip is disabled upstream (rhwp #196).** HWPX input is
accepted, but output is always written as HWP 5.x binary.
- **rhwp v0.7.x is beta.** Complex tables, images, charts, or form fields may
occasionally lose fidelity on round-trip; verify with `info` and visual
render after non-trivial edits.
- **Windows security modules, Hancom GUI automation, read-only distribution
documents beyond `rhwp convert`** are out of scope.
## Upstream references
- rhwp (Rust): <https://github.com/edwardkim/rhwp>
- @rhwp/core (npm): <https://www.npmjs.com/package/@rhwp/core>
- k-skill repo: <https://github.com/NomaDamas/k-skill>
## License
MIT

View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
"use strict";
const { main } = require("../src/cli");
main(process.argv.slice(2)).then(
(code) => {
process.exit(code);
},
(err) => {
process.stderr.write(`k-skill-rhwp: ${err && err.stack ? err.stack : err}\n`);
process.exit(2);
}
);

View file

@ -0,0 +1,43 @@
{
"name": "k-skill-rhwp",
"version": "0.1.0",
"description": "Node-side HWP editing CLI that wraps @rhwp/core WASM bindings for the rhwp-edit and rhwp-advanced skills",
"license": "MIT",
"main": "src/index.js",
"bin": {
"k-skill-rhwp": "bin/k-skill-rhwp.js"
},
"files": [
"src",
"bin",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"hwp",
"hwpx",
"hancom",
"hangul",
"rhwp",
"editor",
"wasm"
],
"dependencies": {
"@rhwp/core": "^0.7.3"
},
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check src/wasm-init.js && node --check bin/k-skill-rhwp.js && node --check test/index.test.js && node --check test/cli.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,279 @@
"use strict";
const path = require("node:path");
const lib = require("./index");
const PKG_VERSION = require("../package.json").version;
const USAGE = `k-skill-rhwp <command> [options]
Commands:
info <input> Print document info as JSON
list-paragraphs <input> [--section N] List paragraph lengths in a section
search <input> --query TEXT [--from-section N] [--from-paragraph N] [--from-char N]
Find first occurrence (forward, body paragraphs only)
insert-text <input> <output> --section N --paragraph N --offset N --text TEXT
delete-text <input> <output> --section N --paragraph N --offset N --count N
replace-all <input> <output> --query TEXT --replacement TEXT [--case-sensitive]
Replace every occurrence (body paragraphs only;
rejects replacements with newline/paragraph breaks)
create-table <input> <output> --section N --paragraph N --offset N --rows N --cols N
set-cell-text <input> <output> --section N --parent-paragraph N --control N --cell N
--text TEXT [--cell-paragraph N] [--no-replace]
create-blank <output> Write a blank HWP document to <output>
render <input> [--page N] [--format svg|html]
Print rendered SVG/HTML to stdout
Scope note:
'search' and 'replace-all' scan body paragraphs only. Text inside table cells,
headers/footers, and footnotes is NOT covered. For cell text, use 'info' or
'list-paragraphs' to locate the table, then 'set-cell-text' to write.
Case-insensitive 'replace-all' (the default) refuses inputs whose case folding
changes UTF-16 length (e.g. Turkish 'İ' U+0130) because those inputs would
silently drift every subsequent offset. Rerun with --case-sensitive for those
documents. ASCII and Hangul workflows are unaffected.
Global options:
--json Output machine-readable JSON (default for info/list/search)
--help, -h Show this help
--version, -v Print package version
Examples:
k-skill-rhwp info sample.hwp
k-skill-rhwp insert-text sample.hwp out.hwp --section 0 --paragraph 0 --offset 0 --text "hello"
k-skill-rhwp replace-all sample.hwp out.hwp --query 2025 --replacement 2026
`;
function parseArgs(argv) {
const args = { _: [], flags: {} };
let i = 0;
while (i < argv.length) {
const token = argv[i];
if (token === "--") {
args._.push(...argv.slice(i + 1));
break;
}
if (token.startsWith("--")) {
const eq = token.indexOf("=");
let name;
let value;
if (eq !== -1) {
name = token.slice(2, eq);
value = token.slice(eq + 1);
} else {
name = token.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith("-")) {
value = true;
} else {
value = next;
i += 1;
}
}
args.flags[name] = value;
} else if (token === "-h") {
args.flags.help = true;
} else if (token === "-v") {
args.flags.version = true;
} else {
args._.push(token);
}
i += 1;
}
return args;
}
function requireFlag(flags, name, { numeric = false } = {}) {
const raw = flags[name];
if (raw === undefined || raw === true || raw === "") {
throw new Error(`missing required --${name}`);
}
if (numeric) {
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`--${name} must be a non-negative integer, got ${raw}`);
}
return n;
}
return String(raw);
}
function optionalNumber(flags, name, fallback) {
if (flags[name] === undefined) return fallback;
const n = Number(flags[name]);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(`--${name} must be a non-negative integer, got ${flags[name]}`);
}
return n;
}
function boolFlag(flags, name) {
return flags[name] === true || flags[name] === "true";
}
function printJson(obj, stdout) {
stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}
function resolveInput(positional, index, label) {
const raw = positional[index];
if (!raw) {
throw new Error(`missing positional argument: ${label}`);
}
return path.resolve(raw);
}
async function dispatch(command, args, { stdout = process.stdout } = {}) {
const { flags } = args;
switch (command) {
case "info": {
const input = resolveInput(args._, 1, "<input>");
const info = await lib.getDocumentInfo(input);
printJson(info, stdout);
return 0;
}
case "list-paragraphs": {
const input = resolveInput(args._, 1, "<input>");
const section = optionalNumber(flags, "section", 0);
const result = await lib.listParagraphs(input, section);
printJson(result, stdout);
return 0;
}
case "search": {
const input = resolveInput(args._, 1, "<input>");
const query = requireFlag(flags, "query");
const fromSection = optionalNumber(flags, "from-section", 0);
const fromParagraph = optionalNumber(flags, "from-paragraph", 0);
const fromChar = optionalNumber(flags, "from-char", 0);
const forward = !boolFlag(flags, "backward");
const caseSensitive = boolFlag(flags, "case-sensitive");
const result = await lib.searchText({
input,
query,
fromSection,
fromParagraph,
fromChar,
forward,
caseSensitive
});
printJson(result, stdout);
return 0;
}
case "insert-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.insertText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
text: requireFlag(flags, "text")
});
printJson(result, stdout);
return 0;
}
case "delete-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.deleteText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
count: requireFlag(flags, "count", { numeric: true })
});
printJson(result, stdout);
return 0;
}
case "replace-all": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.replaceAll({
input,
output,
query: requireFlag(flags, "query"),
replacement: flags.replacement === undefined ? "" : String(flags.replacement),
caseSensitive: boolFlag(flags, "case-sensitive")
});
printJson(result, stdout);
return 0;
}
case "create-table": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.createTable({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
paragraph: requireFlag(flags, "paragraph", { numeric: true }),
offset: requireFlag(flags, "offset", { numeric: true }),
rows: requireFlag(flags, "rows", { numeric: true }),
cols: requireFlag(flags, "cols", { numeric: true })
});
printJson(result, stdout);
return 0;
}
case "set-cell-text": {
const input = resolveInput(args._, 1, "<input>");
const output = resolveInput(args._, 2, "<output>");
const result = await lib.setCellText({
input,
output,
section: requireFlag(flags, "section", { numeric: true }),
parentParagraph: requireFlag(flags, "parent-paragraph", { numeric: true }),
control: requireFlag(flags, "control", { numeric: true }),
cell: requireFlag(flags, "cell", { numeric: true }),
cellParagraph: optionalNumber(flags, "cell-paragraph", 0),
text: requireFlag(flags, "text"),
replace: !boolFlag(flags, "no-replace")
});
printJson(result, stdout);
return 0;
}
case "create-blank": {
const output = resolveInput(args._, 1, "<output>");
const result = await lib.createBlank(output);
printJson(result, stdout);
return 0;
}
case "render": {
const input = resolveInput(args._, 1, "<input>");
const page = optionalNumber(flags, "page", 0);
const format = flags.format ? String(flags.format) : "svg";
const output = await lib.renderPage(input, page, format);
stdout.write(output);
if (!output.endsWith("\n")) stdout.write("\n");
return 0;
}
default:
throw new Error(`unknown command: ${command}`);
}
}
async function main(argv, { stdout = process.stdout, stderr = process.stderr } = {}) {
const parsed = parseArgs(argv);
if (parsed.flags.version === true) {
stdout.write(`k-skill-rhwp ${PKG_VERSION}\n`);
return 0;
}
if (parsed.flags.help === true || parsed._.length === 0) {
stdout.write(USAGE);
return 0;
}
const [command] = parsed._;
try {
return await dispatch(command, parsed, { stdout });
} catch (err) {
stderr.write(`k-skill-rhwp: ${err && err.message ? err.message : err}\n`);
return 1;
}
}
module.exports = {
parseArgs,
main,
USAGE
};

View file

@ -0,0 +1,333 @@
"use strict";
const fs = require("node:fs");
const { getRhwpCore } = require("./wasm-init");
function parseJsonResult(raw, op) {
try {
const parsed = JSON.parse(raw);
if (!parsed || parsed.ok !== true) {
throw new Error(`${op} rejected by rhwp: ${raw}`);
}
return parsed;
} catch (err) {
if (err instanceof SyntaxError) {
throw new Error(`${op} returned non-JSON payload: ${raw}`);
}
throw err;
}
}
async function loadDocument(filePath) {
const core = await getRhwpCore();
const bytes = fs.readFileSync(filePath);
return new core.HwpDocument(new Uint8Array(bytes));
}
async function createBlankDocument() {
const core = await getRhwpCore();
const doc = core.HwpDocument.createEmpty();
doc.createBlankDocument();
return doc;
}
function writeHwp(doc, outputPath) {
const bytes = doc.exportHwp();
fs.writeFileSync(outputPath, Buffer.from(bytes));
return { bytesWritten: bytes.length, outputPath };
}
async function getDocumentInfo(filePath) {
const doc = await loadDocument(filePath);
try {
const info = JSON.parse(doc.getDocumentInfo());
const sectionCount = doc.getSectionCount();
const sections = [];
for (let s = 0; s < sectionCount; s += 1) {
const paragraphCount = doc.getParagraphCount(s);
const paragraphs = [];
for (let p = 0; p < paragraphCount; p += 1) {
paragraphs.push({
paragraphIndex: p,
length: doc.getParagraphLength(s, p)
});
}
sections.push({ sectionIndex: s, paragraphCount, paragraphs });
}
return {
sourceFormat: doc.getSourceFormat(),
pageCount: doc.pageCount(),
sectionCount,
sections,
documentInfo: info
};
} finally {
doc.free();
}
}
async function insertText(options) {
const { input, output, section, paragraph, offset, text } = options;
if (typeof text !== "string" || text.length === 0) {
throw new Error("insertText: text must be a non-empty string");
}
const doc = await loadDocument(input);
try {
const result = parseJsonResult(doc.insertText(section, paragraph, offset, text), "insertText");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function deleteText(options) {
const { input, output, section, paragraph, offset, count } = options;
if (!Number.isInteger(count) || count <= 0) {
throw new Error("deleteText: count must be a positive integer");
}
const doc = await loadDocument(input);
try {
const result = parseJsonResult(doc.deleteText(section, paragraph, offset, count), "deleteText");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
function findAllMatchOffsets(text, query, caseSensitive) {
let hay;
let needle;
if (caseSensitive) {
hay = text;
needle = query;
} else {
hay = text.toLowerCase();
needle = query.toLowerCase();
// Unicode safety: offsets are collected in the lowercased haystack but applied
// back to the original text via replaceText(...). That only round-trips when
// String.prototype.toLowerCase() preserves UTF-16 length. A handful of Unicode
// characters (e.g. 'İ' U+0130 → 'i' U+0069 + combining dot above U+0307) violate
// this, so every subsequent offset drifts and silently corrupts the document.
// Refuse the operation in that case — the user can rerun with caseSensitive:true
// or normalize the input.
if (hay.length !== text.length || needle.length !== query.length) {
throw new Error(
"replaceAll: case-insensitive matching is unsafe because case folding changes the UTF-16 length of the query or a paragraph (e.g. 'İ' U+0130 lowercases to 'i' + combining dot above U+0307). Rerun with caseSensitive:true or normalize the input first."
);
}
}
const offsets = [];
let i = 0;
while (i <= hay.length - needle.length) {
const idx = hay.indexOf(needle, i);
if (idx < 0) break;
offsets.push(idx);
i = idx + needle.length;
}
return offsets;
}
async function replaceAll(options) {
const {
input,
output,
query,
replacement,
caseSensitive = false
} = options;
if (typeof query !== "string" || query.length === 0) {
throw new Error("replaceAll: query must be a non-empty string");
}
const replacementText = replacement == null ? "" : String(replacement);
if (/[\n\r\u2028\u2029]/.test(replacementText)) {
throw new Error(
"replaceAll: replacement must not contain newline or paragraph-break characters; split into multiple edits instead"
);
}
const caseSensitiveFlag = caseSensitive === true;
const doc = await loadDocument(input);
try {
let count = 0;
const sectionCount = doc.getSectionCount();
for (let s = 0; s < sectionCount; s += 1) {
const paraCount = doc.getParagraphCount(s);
for (let p = 0; p < paraCount; p += 1) {
const len = doc.getParagraphLength(s, p);
if (len < query.length) continue;
const text = doc.getTextRange(s, p, 0, len);
const offsets = findAllMatchOffsets(text, query, caseSensitiveFlag);
for (let m = offsets.length - 1; m >= 0; m -= 1) {
parseJsonResult(
doc.replaceText(s, p, offsets[m], query.length, replacementText),
"replaceText"
);
count += 1;
}
}
}
const written = writeHwp(doc, output);
return { ok: true, count, ...written };
} finally {
doc.free();
}
}
async function searchText(options) {
const {
input,
query,
fromSection = 0,
fromParagraph = 0,
fromChar = 0,
forward = true,
caseSensitive = false
} = options;
if (typeof query !== "string" || query.length === 0) {
throw new Error("searchText: query must be a non-empty string");
}
const doc = await loadDocument(input);
try {
const raw = doc.searchText(
query,
fromSection,
fromParagraph,
fromChar,
forward === true,
caseSensitive === true
);
return JSON.parse(raw);
} finally {
doc.free();
}
}
async function createTable(options) {
const { input, output, section, paragraph, offset, rows, cols } = options;
if (!Number.isInteger(rows) || rows <= 0) {
throw new Error("createTable: rows must be a positive integer");
}
if (!Number.isInteger(cols) || cols <= 0) {
throw new Error("createTable: cols must be a positive integer");
}
const doc = await loadDocument(input);
try {
const raw = doc.createTable(section, paragraph, offset, rows, cols);
const result = parseJsonResult(raw, "createTable");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function setCellText(options) {
const {
input,
output,
section,
parentParagraph,
control,
cell,
cellParagraph = 0,
text,
replace = true
} = options;
if (typeof text !== "string") {
throw new Error("setCellText: text must be a string");
}
const doc = await loadDocument(input);
try {
if (replace === true) {
const length = doc.getCellParagraphLength(section, parentParagraph, control, cell, cellParagraph);
if (length > 0) {
parseJsonResult(
doc.deleteTextInCell(
section,
parentParagraph,
control,
cell,
cellParagraph,
0,
length
),
"deleteTextInCell"
);
}
}
const raw = doc.insertTextInCell(
section,
parentParagraph,
control,
cell,
cellParagraph,
0,
text
);
const result = parseJsonResult(raw, "insertTextInCell");
const written = writeHwp(doc, output);
return { ...result, ...written };
} finally {
doc.free();
}
}
async function createBlank(outputPath) {
if (typeof outputPath !== "string" || outputPath.length === 0) {
throw new Error("createBlank: outputPath is required");
}
const doc = await createBlankDocument();
try {
return writeHwp(doc, outputPath);
} finally {
doc.free();
}
}
async function listParagraphs(filePath, sectionIndex = 0) {
const doc = await loadDocument(filePath);
try {
const count = doc.getParagraphCount(sectionIndex);
const paragraphs = [];
for (let p = 0; p < count; p += 1) {
paragraphs.push({
paragraphIndex: p,
length: doc.getParagraphLength(sectionIndex, p)
});
}
return { sectionIndex, paragraphCount: count, paragraphs };
} finally {
doc.free();
}
}
async function renderPage(filePath, pageIndex = 0, format = "svg") {
const doc = await loadDocument(filePath);
try {
if (format === "html") return doc.renderPageHtml(pageIndex);
if (format === "svg") return doc.renderPageSvg(pageIndex);
throw new Error(`renderPage: unknown format '${format}' (expected 'svg' or 'html')`);
} finally {
doc.free();
}
}
module.exports = {
getRhwpCore,
loadDocument,
createBlankDocument,
writeHwp,
getDocumentInfo,
insertText,
deleteText,
replaceAll,
searchText,
createTable,
setCellText,
createBlank,
listParagraphs,
renderPage,
parseJsonResult
};

View file

@ -0,0 +1,102 @@
"use strict";
/**
* Lazy WASM initialization for @rhwp/core in Node.js.
*
* @rhwp/core is an ESM package shipping a WebAssembly binary meant for browsers.
* Two Node-specific concerns are handled here:
*
* 1. The WASM imports a `globalThis.measureTextWidth(font, text)` callback that is
* used for text layout (line breaking, justification). The canonical browser
* implementation uses a `<canvas>` 2D context. Headless Node has no Canvas.
* We install a deterministic approximation shim that treats CJK code points as
* full-width ( font size) and Latin as half-width ( 0.55 × font size).
* Accurate enough for round-trip editing and smoke tests; do not rely on it
* for pixel-perfect rendering.
*
* 2. The default `init(undefined)` path assumes `import.meta.url` and `fetch()`,
* neither of which point at a local filesystem WASM binary in Node. We
* resolve the shipped `rhwp_bg.wasm` via `require.resolve` and hand its bytes
* to init explicitly, which avoids any fetch/network I/O.
*
* The module is side-effect free: calling `getRhwpCore()` more than once returns
* the same promise. Callers are expected to reuse the resolved module object.
*/
const fs = require("node:fs");
let initPromise = null;
/**
* Install the `measureTextWidth` shim on globalThis if none exists yet.
*
* A user-supplied shim (for example node-canvas based) takes precedence.
*
* @returns {boolean} true when a shim was installed by this call.
*/
function installMeasureTextWidthShim() {
if (typeof globalThis.measureTextWidth === "function") {
return false;
}
globalThis.measureTextWidth = (font, text) => {
const match = String(font || "").match(/([0-9.]+)px/);
const size = match ? parseFloat(match[1]) : 12;
let width = 0;
const source = String(text || "");
for (const ch of source) {
const cp = ch.codePointAt(0) ?? 0;
// CJK / full-width Hangul-Jamo..Halfwidth block roughly fills a square.
// Latin, digits, punctuation are closer to half-width.
if (cp >= 0x1100 && cp <= 0xffdc) {
width += size;
} else {
width += size * 0.55;
}
}
return width;
};
return true;
}
/**
* Resolve the absolute path of the @rhwp/core WASM binary shipped with the
* package. The resolution is deliberately subpath-based so workspaces, globally
* linked copies, and hoisted node_modules all work.
*
* @returns {string}
*/
function resolveRhwpWasmPath() {
return require.resolve("@rhwp/core/rhwp_bg.wasm");
}
/**
* Lazily load and initialize @rhwp/core. Returns the ESM namespace object so
* callers can use `HwpDocument`, `version`, `extractThumbnail`, etc.
*
* Safe to call many times; the WASM is initialized exactly once.
*
* @returns {Promise<typeof import("@rhwp/core")>}
*/
function getRhwpCore() {
if (initPromise) return initPromise;
initPromise = (async () => {
installMeasureTextWidthShim();
const core = await import("@rhwp/core");
const wasmPath = resolveRhwpWasmPath();
const wasmBytes = fs.readFileSync(wasmPath);
await core.default({ module_or_path: wasmBytes });
return core;
})();
return initPromise;
}
function _resetForTests() {
initPromise = null;
}
module.exports = {
getRhwpCore,
installMeasureTextWidthShim,
resolveRhwpWasmPath,
_resetForTests
};

View file

@ -0,0 +1,159 @@
"use strict";
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 { Writable } = require("node:stream");
const { parseArgs, main, USAGE } = require("../src/cli");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-cli-"));
test.after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function collectStream() {
const chunks = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk);
cb();
}
});
Object.defineProperty(stream, "buffer", {
get: () => Buffer.concat(chunks).toString("utf8")
});
return stream;
}
test("parseArgs handles positional args, --flag value, --flag=value, and boolean flags", () => {
const a = parseArgs([
"insert-text",
"in.hwp",
"out.hwp",
"--section",
"0",
"--paragraph=1",
"--text",
"hi",
"--case-sensitive"
]);
assert.deepEqual(a._, ["insert-text", "in.hwp", "out.hwp"]);
assert.equal(a.flags.section, "0");
assert.equal(a.flags.paragraph, "1");
assert.equal(a.flags.text, "hi");
assert.equal(a.flags["case-sensitive"], true);
});
test("parseArgs supports -- to pass remaining tokens positionally", () => {
const a = parseArgs(["cmd", "--", "--weird-text", "with --dashes"]);
assert.deepEqual(a._, ["cmd", "--weird-text", "with --dashes"]);
});
test("main prints usage with --help", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["--help"], { stdout, stderr });
assert.equal(code, 0);
assert.ok(stdout.buffer.includes("k-skill-rhwp"));
assert.ok(stdout.buffer.includes("info <input>"));
assert.equal(stderr.buffer, "");
assert.ok(USAGE.length > 100);
});
test("main prints version with -v", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["-v"], { stdout, stderr });
assert.equal(code, 0);
assert.match(stdout.buffer, /k-skill-rhwp \d+\.\d+\.\d+/);
});
test("main returns exit code 1 for unknown command with a helpful message", async () => {
const stdout = collectStream();
const stderr = collectStream();
const code = await main(["totally-fake-command"], { stdout, stderr });
assert.equal(code, 1);
assert.match(stderr.buffer, /unknown command: totally-fake-command/);
});
test("main info on a generated blank document prints valid JSON with sectionCount >= 1", async () => {
const blank = path.join(tmpRoot, "cli-blank.hwp");
const createOut = collectStream();
const createErr = collectStream();
const createCode = await main(["create-blank", blank], {
stdout: createOut,
stderr: createErr
});
assert.equal(createCode, 0, `create-blank failed: ${createErr.buffer}`);
assert.ok(fs.existsSync(blank));
const infoOut = collectStream();
const infoErr = collectStream();
const infoCode = await main(["info", blank], { stdout: infoOut, stderr: infoErr });
assert.equal(infoCode, 0, `info failed: ${infoErr.buffer}`);
const parsed = JSON.parse(infoOut.buffer);
assert.equal(parsed.sourceFormat, "hwp");
assert.equal(parsed.sectionCount, 1);
assert.ok(Array.isArray(parsed.sections));
});
test("main insert-text + info end-to-end round-trip through the CLI layer", async () => {
const blank = path.join(tmpRoot, "cli-rt-blank.hwp");
const edited = path.join(tmpRoot, "cli-rt-edited.hwp");
const nul = collectStream();
await main(["create-blank", blank], { stdout: nul, stderr: collectStream() });
const insertOut = collectStream();
const insertErr = collectStream();
const insertCode = await main(
[
"insert-text",
blank,
edited,
"--section",
"0",
"--paragraph",
"0",
"--offset",
"0",
"--text",
"안녕 CLI"
],
{ stdout: insertOut, stderr: insertErr }
);
assert.equal(insertCode, 0, `insert-text failed: ${insertErr.buffer}`);
const insertResult = JSON.parse(insertOut.buffer);
assert.equal(insertResult.ok, true);
const infoOut = collectStream();
await main(["info", edited], { stdout: infoOut, stderr: collectStream() });
const infoResult = JSON.parse(infoOut.buffer);
assert.equal(infoResult.sections[0].paragraphs[0].length, "안녕 CLI".length);
});
test("main reports missing required --section flag to stderr and exits 1", async () => {
const blank = path.join(tmpRoot, "cli-missing-flag.hwp");
await main(["create-blank", blank], { stdout: collectStream(), stderr: collectStream() });
const stdout = collectStream();
const stderr = collectStream();
const code = await main(
[
"insert-text",
blank,
path.join(tmpRoot, "cli-missing-out.hwp"),
"--paragraph",
"0",
"--offset",
"0",
"--text",
"hi"
],
{ stdout, stderr }
);
assert.equal(code, 1);
assert.match(stderr.buffer, /missing required --section/);
});

View file

@ -0,0 +1,593 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
createBlank,
createBlankDocument,
getDocumentInfo,
insertText,
deleteText,
replaceAll,
searchText,
createTable,
setCellText,
listParagraphs,
renderPage,
parseJsonResult
} = require("../src/index");
const {
getRhwpCore,
installMeasureTextWidthShim,
resolveRhwpWasmPath
} = require("../src/wasm-init");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-test-"));
test.after(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
function tempPath(name) {
return path.join(tmpRoot, name);
}
function sha1(filePath) {
return crypto.createHash("sha1").update(fs.readFileSync(filePath)).digest("hex");
}
async function newBlankFixture(name = "blank.hwp") {
const target = tempPath(name);
const doc = await createBlankDocument();
try {
const bytes = doc.exportHwp();
fs.writeFileSync(target, Buffer.from(bytes));
} finally {
doc.free();
}
return target;
}
test("installMeasureTextWidthShim installs a deterministic shim only once", () => {
const originalShim = globalThis.measureTextWidth;
delete globalThis.measureTextWidth;
try {
const first = installMeasureTextWidthShim();
assert.equal(first, true);
assert.equal(typeof globalThis.measureTextWidth, "function");
const again = installMeasureTextWidthShim();
assert.equal(again, false);
const latinWidth = globalThis.measureTextWidth("12px serif", "abc");
const cjkWidth = globalThis.measureTextWidth("12px serif", "가나다");
assert.ok(cjkWidth > latinWidth, `expected CJK ${cjkWidth} > latin ${latinWidth}`);
} finally {
globalThis.measureTextWidth = originalShim;
}
});
test("resolveRhwpWasmPath resolves the shipped @rhwp/core wasm binary", () => {
const wasmPath = resolveRhwpWasmPath();
assert.ok(path.isAbsolute(wasmPath), `expected absolute path, got ${wasmPath}`);
assert.ok(wasmPath.endsWith("rhwp_bg.wasm"), `unexpected wasm path ${wasmPath}`);
const stat = fs.statSync(wasmPath);
assert.ok(stat.size > 1024 * 1024, `wasm binary suspiciously small: ${stat.size}`);
});
test("parseJsonResult rejects non-JSON and {ok:false}", () => {
assert.throws(() => parseJsonResult("not-json", "x"), /non-JSON payload/);
assert.throws(() => parseJsonResult(JSON.stringify({ ok: false }), "x"), /rejected by rhwp/);
const ok = parseJsonResult(JSON.stringify({ ok: true, value: 1 }), "x");
assert.deepEqual(ok, { ok: true, value: 1 });
});
test("getRhwpCore returns a cached module with HwpDocument constructor", async () => {
const mod = await getRhwpCore();
assert.equal(typeof mod.HwpDocument, "function");
assert.equal(typeof mod.version, "function");
const again = await getRhwpCore();
assert.equal(again, mod, "getRhwpCore must return cached module");
});
test("createBlank writes a valid HWP file that round-trips via getDocumentInfo", async () => {
const target = tempPath("blank-via-cli-api.hwp");
const result = await createBlank(target);
assert.ok(result.bytesWritten > 1024, `blank HWP suspiciously small: ${result.bytesWritten}`);
assert.equal(result.outputPath, target);
assert.ok(fs.existsSync(target));
const info = await getDocumentInfo(target);
assert.equal(info.sourceFormat, "hwp");
assert.equal(info.sectionCount, 1);
assert.ok(info.pageCount >= 1);
assert.equal(info.sections[0].sectionIndex, 0);
assert.equal(info.sections[0].paragraphCount, 1);
});
test("insertText inserts text at paragraph start and round-trips on disk", async () => {
const src = await newBlankFixture("insert-src.hwp");
const dst = tempPath("insert-dst.hwp");
const result = await insertText({
input: src,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
text: "안녕하세요 rhwp!"
});
assert.equal(result.ok, true);
assert.equal(typeof result.charOffset, "number");
assert.ok(result.bytesWritten > 1024);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "안녕하세요 rhwp!".length);
});
test("insertText rejects empty text synchronously", async () => {
const src = await newBlankFixture("insert-empty-src.hwp");
const dst = tempPath("insert-empty-dst.hwp");
await assert.rejects(
insertText({ input: src, output: dst, section: 0, paragraph: 0, offset: 0, text: "" }),
/non-empty string/
);
assert.equal(fs.existsSync(dst), false, "no file should be written on validation error");
});
test("deleteText removes characters and shortens the paragraph", async () => {
const src = await newBlankFixture("delete-src.hwp");
const mid = tempPath("delete-mid.hwp");
const dst = tempPath("delete-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "abcdef"
});
const result = await deleteText({
input: mid,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
count: 3
});
assert.equal(result.ok, true);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, 3);
});
test("deleteText rejects non-positive counts", async () => {
const src = await newBlankFixture("delete-zero-src.hwp");
const dst = tempPath("delete-zero-dst.hwp");
await assert.rejects(
deleteText({ input: src, output: dst, section: 0, paragraph: 0, offset: 0, count: 0 }),
/positive integer/
);
});
test("replaceAll persists same-length replacement into the output bytes (regression for silent no-op)", async () => {
const src = await newBlankFixture("replace-same-src.hwp");
const mid = tempPath("replace-same-mid.hwp");
const dst = tempPath("replace-same-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "2025 2025 2025"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "2025",
replacement: "2026"
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, `expected 3 replacements, got ${result.count}`);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "2026 2026 2026".length);
assert.notEqual(sha1(mid), sha1(dst), "replaceAll output must differ from input bytes");
const hitNew = await searchText({ input: dst, query: "2026" });
assert.equal(hitNew.found, true, "replacement 2026 must be findable after replaceAll");
assert.equal(hitNew.sec, 0);
assert.equal(hitNew.para, 0);
const hitOld = await searchText({ input: dst, query: "2025" });
assert.equal(hitOld.found, false, "query 2025 must not be findable after replaceAll");
});
test("replaceAll persists LONGER-length replacement and grows paragraph length by the correct amount", async () => {
const src = await newBlankFixture("replace-longer-src.hwp");
const mid = tempPath("replace-longer-mid.hwp");
const dst = tempPath("replace-longer-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "2026년 테스트"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "2026",
replacement: "이천이십칠"
});
assert.equal(result.ok, true);
assert.equal(result.count, 1);
const info = await getDocumentInfo(dst);
assert.equal(
info.sections[0].paragraphs[0].length,
"이천이십칠년 테스트".length,
"paragraph length must grow when replacement is longer than query"
);
assert.notEqual(sha1(mid), sha1(dst));
assert.equal((await searchText({ input: dst, query: "이천이십칠" })).found, true);
assert.equal((await searchText({ input: dst, query: "2026" })).found, false);
});
test("replaceAll persists SHORTER-length replacement and shrinks paragraph length by the correct amount", async () => {
const src = await newBlankFixture("replace-shorter-src.hwp");
const mid = tempPath("replace-shorter-mid.hwp");
const dst = tempPath("replace-shorter-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "longlonglong tail"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "longlonglong",
replacement: "AB"
});
assert.equal(result.ok, true);
assert.equal(result.count, 1);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "AB tail".length);
assert.notEqual(sha1(mid), sha1(dst));
});
test("replaceAll with empty replacement deletes every match (delete-to-empty)", async () => {
const src = await newBlankFixture("replace-delete-src.hwp");
const mid = tempPath("replace-delete-mid.hwp");
const dst = tempPath("replace-delete-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "foo-X-bar-X-baz"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "-X-",
replacement: ""
});
assert.equal(result.ok, true);
assert.equal(result.count, 2);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "foobarbaz".length);
assert.equal((await searchText({ input: dst, query: "-X-" })).found, false);
});
test("replaceAll handles replacement containing the query without infinite loop (non-overlapping semantics)", async () => {
const src = await newBlankFixture("replace-contains-src.hwp");
const mid = tempPath("replace-contains-mid.hwp");
const dst = tempPath("replace-contains-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "aaa"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "a",
replacement: "aa"
});
assert.equal(result.ok, true);
assert.equal(result.count, 3, "non-overlapping replace: each original 'a' matched once, not each expanded one");
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, 6, "'aaa' with a→aa produces 'aaaaaa' under non-overlapping semantics");
});
test("replaceAll with zero matches writes output and reports count 0", async () => {
const src = await newBlankFixture("replace-none-src.hwp");
const mid = tempPath("replace-none-mid.hwp");
const dst = tempPath("replace-none-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "no matches here"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "XYZ",
replacement: "ABC"
});
assert.equal(result.ok, true);
assert.equal(result.count, 0);
const info = await getDocumentInfo(dst);
assert.equal(info.sections[0].paragraphs[0].length, "no matches here".length);
});
test("replaceAll honors case-sensitive flag", async () => {
const src = await newBlankFixture("replace-case-src.hwp");
const mid = tempPath("replace-case-mid.hwp");
const dst = tempPath("replace-case-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "abc ABC abc"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "abc",
replacement: "xyz",
caseSensitive: true
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-sensitive replacement must skip ABC");
assert.equal((await searchText({ input: dst, query: "ABC", caseSensitive: true })).found, true);
});
test("replaceAll rejects replacement containing newlines (paragraph-break scope guard)", async () => {
const src = await newBlankFixture("replace-newline-src.hwp");
const mid = tempPath("replace-newline-mid.hwp");
const dst = tempPath("replace-newline-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "hello"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "hello",
replacement: "multi\nline"
}),
/newline|paragraph/i,
"replaceAll must refuse replacements containing paragraph-break characters"
);
});
test("replaceAll refuses case-insensitive matching when source text contains case-folding length-changing chars (e.g. Turkish İ U+0130)", async () => {
// Regression: without the guard, `ABCİABCİXYZ` + case-insensitive `İ → Z` reported
// { ok:true, count:2 } but silently produced `ABCZABCİZYZ` (the X at index 8 was
// corrupted while the second İ was left untouched). This is because
// String.prototype.toLowerCase() maps İ (U+0130) to i + combining dot above
// (U+0069 U+0307), which changes UTF-16 length and drifts every subsequent offset.
const src = await newBlankFixture("replace-unicode-drift-src.hwp");
const mid = tempPath("replace-unicode-drift-mid.hwp");
const dst = tempPath("replace-unicode-drift-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "ABCİABCİXYZ"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "Z"
}),
/case.?insensitive|case.?fold|UTF-?16|U\+0130/i,
"replaceAll must refuse case-insensitive matching on inputs with length-changing case folding"
);
assert.equal(
fs.existsSync(dst),
false,
"no output file should be written when replaceAll rejects case-insensitive drift"
);
});
test("replaceAll refuses case-insensitive matching when the query itself contains case-folding length-changing chars", async () => {
const src = await newBlankFixture("replace-unicode-query-src.hwp");
const mid = tempPath("replace-unicode-query-mid.hwp");
const dst = tempPath("replace-unicode-query-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "plain ascii text"
});
await assert.rejects(
replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "X"
}),
/case.?insensitive|case.?fold|UTF-?16|U\+0130/i,
"replaceAll must refuse case-insensitive matching when the query has length-changing case folding"
);
assert.equal(fs.existsSync(dst), false);
});
test("replaceAll with --case-sensitive succeeds on inputs containing İ (guard only applies to case-insensitive path)", async () => {
const src = await newBlankFixture("replace-unicode-case-src.hwp");
const mid = tempPath("replace-unicode-case-mid.hwp");
const dst = tempPath("replace-unicode-case-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "ABCİABCİXYZ"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "İ",
replacement: "Z",
caseSensitive: true
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-sensitive replacement must hit both İ occurrences");
const info = await getDocumentInfo(dst);
assert.equal(
info.sections[0].paragraphs[0].length,
"ABCZABCZXYZ".length,
"paragraph length must match fully-replaced output (both İ → Z, X stays)"
);
assert.equal(
(await searchText({ input: dst, query: "İ", caseSensitive: true })).found,
false,
"İ must be gone from case-sensitive output"
);
assert.equal(
(await searchText({ input: dst, query: "X", caseSensitive: true })).found,
true,
"X must be preserved (not corrupted by offset drift)"
);
});
test("replaceAll case-insensitive still works for normal ASCII/Hangul that do not change UTF-16 length under toLowerCase", async () => {
// Regression guard: the Unicode fix must not break the common case.
const src = await newBlankFixture("replace-unicode-ok-src.hwp");
const mid = tempPath("replace-unicode-ok-mid.hwp");
const dst = tempPath("replace-unicode-ok-dst.hwp");
await insertText({
input: src,
output: mid,
section: 0,
paragraph: 0,
offset: 0,
text: "hello WORLD 안녕 HELLO"
});
const result = await replaceAll({
input: mid,
output: dst,
query: "hello",
replacement: "hi"
});
assert.equal(result.ok, true);
assert.equal(result.count, 2, "case-insensitive must still match both 'hello' and 'HELLO'");
});
test("searchText reports a match location for present text", async () => {
const src = await newBlankFixture("search-src.hwp");
const edited = tempPath("search-edited.hwp");
await insertText({
input: src,
output: edited,
section: 0,
paragraph: 0,
offset: 0,
text: "find-me-please"
});
const hit = await searchText({ input: edited, query: "please" });
assert.equal(typeof hit, "object");
assert.ok(hit, "searchText must return a match payload");
assert.equal(hit.found, true);
});
test("createTable inserts a table and grows paragraph count", async () => {
const src = await newBlankFixture("table-src.hwp");
const dst = tempPath("table-dst.hwp");
const result = await createTable({
input: src,
output: dst,
section: 0,
paragraph: 0,
offset: 0,
rows: 2,
cols: 3
});
assert.equal(result.ok, true);
const info = await getDocumentInfo(dst);
assert.ok(info.sections[0].paragraphCount >= 1);
});
test("setCellText fills a cell after creating a table", async () => {
const src = await newBlankFixture("cell-src.hwp");
const tableFile = tempPath("cell-table.hwp");
const tableResult = await createTable({
input: src,
output: tableFile,
section: 0,
paragraph: 0,
offset: 0,
rows: 2,
cols: 2
});
assert.equal(tableResult.ok, true);
assert.equal(typeof tableResult.paraIdx, "number");
assert.equal(typeof tableResult.controlIdx, "number");
const filled = tempPath("cell-filled.hwp");
const cellResult = await setCellText({
input: tableFile,
output: filled,
section: 0,
parentParagraph: tableResult.paraIdx,
control: tableResult.controlIdx,
cell: 0,
cellParagraph: 0,
text: "A1 cell"
});
assert.equal(cellResult.ok, true);
assert.ok(fs.existsSync(filled));
});
test("listParagraphs returns per-paragraph lengths for a section", async () => {
const src = await newBlankFixture("list-src.hwp");
const edited = tempPath("list-edited.hwp");
await insertText({
input: src,
output: edited,
section: 0,
paragraph: 0,
offset: 0,
text: "para1"
});
const listing = await listParagraphs(edited, 0);
assert.equal(listing.sectionIndex, 0);
assert.equal(listing.paragraphCount, 1);
assert.equal(listing.paragraphs[0].length, 5);
});
test("renderPage returns SVG markup with <svg> wrapper for a blank document", async () => {
const src = await newBlankFixture("render-src.hwp");
const svg = await renderPage(src, 0, "svg");
assert.match(svg, /<svg[^>]*>/);
assert.match(svg, /<\/svg>/);
});
test("renderPage rejects unknown format", async () => {
const src = await newBlankFixture("render-bad-src.hwp");
await assert.rejects(renderPage(src, 0, "pdf"), /unknown format/);
});

155
rhwp-advanced/SKILL.md Normal file
View file

@ -0,0 +1,155 @@
---
name: rhwp-advanced
description: Debug HWP layout, dump document IR, compare versions, extract thumbnails, and unlock read-only HWPs with the upstream rhwp Rust CLI (export-svg/dump/dump-pages/ir-diff/thumbnail/convert).
license: MIT
metadata:
category: documents
locale: ko-KR
phase: v1.5
---
# rhwp-advanced
## What this skill does
**업스트림 `rhwp` CLI**(Rust 네이티브 바이너리)를 써서 HWP 파일의 **레이아웃 디버깅·IR 구조 검사·버전 비교·썸네일 추출·배포용 문서 잠금 해제** 를 수행한다.
`k-skill-rhwp`(Node 편집 CLI)가 다루지 못하는 구조 분석·렌더 문제 진단용이다.
이 스킬은 **편집을 하지 않는다**. 편집은 [`rhwp-edit`](../rhwp-edit/SKILL.md) 스킬, 문서 → Markdown/JSON 변환은 [`hwp`](../hwp/SKILL.md) 스킬(kordoc) 을 쓴다.
## When to use
- "표/셀이 이상하게 잘려. 어디서 깨지는지 IR 덤프를 보고 싶어"
- "두 HWP 파일 구조 차이를 줄 단위로 보고 싶어"
- "SVG 렌더가 이상해. 문단/표 경계선을 시각적으로 확인하고 싶어"
- "페이지가 몇 개이고 어느 문단이 몇 페이지에 걸쳐 있는지 보고 싶어"
- "배포용(읽기전용) HWP 파일 잠금을 풀고 싶어"
- "HWP 파일에서 PrvImage 썸네일을 꺼내고 싶어"
## When not to use
- **텍스트/표 편집**`rhwp-edit` 스킬 (`k-skill-rhwp` CLI)
- **HWP → Markdown/JSON/양식필드 변환**`hwp` 스킬 (`kordoc`)
- **GUI 자동화, 한컴 보안모듈 우회, Windows 레지스트리 제어** → 범위 밖이다.
- **Node 코드에서 라이브러리 API 로 편집**`k-skill-rhwp` 를 Node API 로 쓴다.
## Prerequisites
- **rhwp CLI 바이너리**. 다음 중 하나:
- `cargo install rhwp` (Rust toolchain 필요. Rust 1.75+. 네이티브 빌드이므로 PDF export 포함 전체 서브커맨드 가능)
- 또는 업스트림 릴리스 페이지 https://github.com/edwardkim/rhwp/releases 에서 플랫폼별 사전 빌드 바이너리 다운로드(있는 플랫폼 한정)
- PATH 에 `rhwp` 실행 파일이 있는지 `rhwp --help` 로 먼저 확인한다.
- 출력 파일 쓰기 권한.
- 선택: PDF export 를 쓸 거면 업스트림 문서에서 해당 서브커맨드의 추가 요구사항 확인.
## Inputs
- 입력 HWP/HWPX 파일 경로
- 서브커맨드별 좌표(구역/문단 index) 또는 페이지 번호
- 출력 경로(일부 서브커맨드)
## Routing policy
| 목적 | 서브커맨드 | 대표 예시 |
| --- | --- | --- |
| 기본 메타(페이지/폰트/섹션 통계) | `rhwp info` | `rhwp info sample.hwp` |
| 페이지를 SVG 로 렌더 | `rhwp export-svg` | `rhwp export-svg sample.hwp -o out/ -p 0 --debug-overlay` |
| 페이지를 PDF 로 렌더(네이티브 빌드 한정) | `rhwp export-pdf` | `rhwp export-pdf sample.hwp -o out.pdf` |
| 문서 IR 구조 덤프 | `rhwp dump` | `rhwp dump sample.hwp -s 0 -p 3` |
| 페이지네이션 결과 덤프 | `rhwp dump-pages` | `rhwp dump-pages sample.hwp -p 2` |
| 원시 레코드 덤프 | `rhwp dump-records` | `rhwp dump-records sample.hwp` |
| 번호/글머리표/개요 진단 | `rhwp diag` | `rhwp diag sample.hwp` |
| 두 파일 IR 비교 | `rhwp ir-diff` | `rhwp ir-diff a.hwpx b.hwp` |
| PrvImage 썸네일 추출 | `rhwp thumbnail` | `rhwp thumbnail sample.hwp -o thumb.png` |
| 배포용(읽기전용) → 편집 가능 변환 | `rhwp convert` | `rhwp convert locked.hwp unlocked.hwp` |
| 빈 표 포함 문서 템플릿 생성 | `rhwp gen-table` | `rhwp gen-table out.hwp` |
> `rhwp` v0.7.3 CLI 에는 **편집(edit/insert-text/save) 서브커맨드가 없다.** 편집은 `rhwp-edit` 스킬 (`k-skill-rhwp` CLI) 을 쓴다.
## Workflow
1. **설치 확인**: `rhwp --help` 실행. 서브커맨드 리스트가 나오지 않으면 설치부터.
```bash
command -v rhwp || cargo install rhwp
rhwp --help | head
```
2. **메타 조회로 좌표 범위 확인**: 먼저 `rhwp info` 로 페이지 수, 섹션 수, 사용 폰트, 표/이미지 통계를 얻는다.
```bash
rhwp info sample.hwp
```
3. **목적별 플로우**:
- **SVG 렌더가 이상할 때** — 디버그 오버레이 포함 SVG 를 뽑는다.
```bash
mkdir -p out
rhwp export-svg sample.hwp -o out/ -p 0 --debug-overlay
open out/page-0.svg # 문단/표 경계선과 `s{sec}:pi={idx} y={y}` 라벨이 시각화됨
```
- **특정 페이지 레이아웃을 더 자세히 보고 싶을 때** — 페이지네이션 덤프.
```bash
rhwp dump-pages sample.hwp -p 2
```
- **표가 깨져 보일 때** — IR 덤프에서 셀 구조·ParaShape·LINE_SEG 를 본다.
```bash
rhwp dump sample.hwp -s 0 -p 3
```
- **두 버전 비교** — IR diff 로 구조 변경만 추린다.
```bash
rhwp ir-diff draft-v1.hwp draft-v2.hwp > ir-diff.txt
```
- **썸네일 추출**:
```bash
rhwp thumbnail sample.hwp -o cover.png
# 또는 data URI 가 필요하면: --data-uri
```
- **배포용(읽기전용) 문서 잠금 해제**:
```bash
rhwp convert locked.hwp unlocked.hwp
# 이후 편집은 rhwp-edit 스킬의 k-skill-rhwp CLI 로 수행
```
4. **결과를 PR/보고서에 붙일 때**: SVG/PDF/썸네일은 파일 자체를 첨부하고, 덤프 출력은 너무 길면 상위 200~500 줄만 인용하고 전체는 파일로 첨부한다. 개인정보가 포함된 문서의 본문 텍스트는 마스킹한다.
## Verify outputs
- `export-svg`: 지정한 `-o` 경로에 `page-N.svg` 파일이 생겼고, 열었을 때 텍스트/도형이 보이며 `--debug-overlay` 사용 시 빨강/파랑 가이드선이 나타난다.
- `dump` / `dump-pages` / `dump-records`: stdout 에 JSON/텍스트 구조가 최소 수십 줄 이상 나온다.
- `ir-diff`: 두 파일이 구조적으로 같으면 거의 비어 있고, 다르면 줄 단위 delta 가 보인다.
- `thumbnail`: 지정한 출력 경로의 PNG 가 실제 이미지 뷰어에서 열린다.
- `convert`: 출력 파일을 다시 `rhwp info` 로 열었을 때 read-only 플래그가 내려가 있다.
## Done when
- 디버깅/검사 목적이라면: 사용자가 원한 구조/렌더 정보가 찍혀 있고 어느 서브커맨드 어떤 플래그로 뽑았는지 명시돼 있다.
- `convert` 같은 one-shot 변환이라면: 산출 파일이 생성되었고 `rhwp info` 로 재확인 가능.
## Failure modes
- **`rhwp: command not found`** → `cargo install rhwp` 혹은 릴리스 바이너리 설치부터.
- **`export-pdf` 실패** → PDF 는 네이티브 빌드에서만 보장. `@rhwp/core` WASM 경로에서는 불가. 네이티브 `cargo install` 바이너리로 실행 중인지 확인.
- **HWPX 저장 경로 비활성화(rhwp #196)**`rhwp` CLI 자체가 HWPX 를 다시 HWPX 로 내보내지 않도록 막아둔 상태. 저장이 필요한 작업은 HWP 5.x 로만 수행한다.
- **편집 서브커맨드 부재** → v0.7.3 기준 `rhwp` CLI 는 편집 명령을 제공하지 않는다. 편집은 `rhwp-edit` 스킬.
- **Windows 보안모듈/한컴 GUI 자동화** → 본 스킬 범위 밖. `rhwp` 는 파일 포맷 엔진이다.
- **버전 드리프트** → rhwp 는 활발히 개발 중이다. 서브커맨드 플래그가 바뀌거나 추가될 수 있으니 `rhwp <subcommand> --help` 를 먼저 확인한다.
## Notes
- 업스트림: https://github.com/edwardkim/rhwp
- 편집 경로(이 repo): [`rhwp-edit`](../rhwp-edit/SKILL.md)
- 조회/변환 경로(이 repo): [`hwp`](../hwp/SKILL.md)
- 이 스킬은 **설치 안내 + 실행 레시피**에 가까운 안내형 스킬이다. 프로그램적 제어가 필요하면 `rhwp-edit` 의 Node API(`k-skill-rhwp`)를 쓰고, 여기서는 빠른 디버깅용으로만 사용한다.

163
rhwp-edit/SKILL.md Normal file
View file

@ -0,0 +1,163 @@
---
name: rhwp-edit
description: Edit HWP documents — insert/delete text, replace-all, create tables, set cell text — with the k-skill-rhwp CLI that wraps the @rhwp/core WASM engine (rhwp by Edward Kim).
license: MIT
metadata:
category: documents
locale: ko-KR
phase: v1.5
---
# rhwp-edit
## What this skill does
`k-skill-rhwp` CLI로 `.hwp` 문서의 **본문 텍스트**, **표 구조**, **셀 내용**을 round-trip 안전하게 수정한다.
CLI는 `@rhwp/core`(Rust + WebAssembly) 위에 얇은 Node 래퍼를 씌워 `insertText`, `deleteText`, `replaceAll`,
`createTable`, `setCellText` 같은 편집 동작을 서브커맨드로 노출한다. 결과는 항상 새 파일로 저장한다.
이 스킬은 **편집 전용**이다. 문서를 Markdown/JSON으로 변환하거나 필드만 추출하려면 [`hwp`](../hwp/SKILL.md) 스킬을 사용한다.
페이지 렌더링 디버깅이나 IR 비교가 필요하면 [`rhwp-advanced`](../rhwp-advanced/SKILL.md) 스킬을 사용한다.
## When to use
- "HWP 본문에 한 줄 추가해줘"
- "서식은 유지한 채로 2025를 2026으로 일괄 치환해줘"
- "3행 4열짜리 표를 HWP에 넣어줘"
- "표의 특정 셀 내용을 바꿔줘"
- "빈 HWP 새 파일을 만들어줘"
## When not to use
- **HWP → Markdown / JSON 변환**`hwp` 스킬(kordoc)을 쓴다. rhwp-edit은 바이너리 편집 전용이다.
- **HWPX 원본을 다시 HWPX로 저장** → rhwp v0.7.3 기준 업스트림이 `#196`으로 HWPX 저장 경로를 막아둔 상태다.
HWPX를 입력으로 주면 내부적으로 HWP IR로 올라온 뒤 **HWP 5.x 바이너리로만** 저장된다. HWPX 출력이 꼭 필요하면 kordoc `markdownToHwpx`를 쓴다.
- **레이아웃(페이지네이션·SVG 렌더) 디버깅**`rhwp-advanced` 스킬로 업스트림 `rhwp` CLI(`export-svg --debug-overlay`, `dump-pages`, `ir-diff`)를 사용한다.
- **배포용(읽기전용) 잠금 해제 · IR 구조 덤프 · 썸네일 추출 등 고급 검사 명령**`rhwp-advanced` 스킬 참조.
- **한컴 오피스 GUI 자동화, 보안모듈 통과, Windows 전용 서식** → 범위 밖이다. `rhwp`는 파일 포맷 엔진이지 GUI 제어가 아니다.
## Prerequisites
- Node.js 18+
- 쓰기 권한이 있는 출력 경로
- `k-skill-rhwp` 설치(셋 중 하나):
- 일회성: `npx --yes k-skill-rhwp --help`
- 전역: `npm install -g k-skill-rhwp`
- 로컬: `npm install k-skill-rhwp`
- `k-skill-rhwp``@rhwp/core@^0.7.3`을 peer 없이 dependency로 끌어온다. 별도 설치 불필요.
- Rust/Cargo toolchain 불필요. 업스트림 `rhwp` CLI를 같이 쓰고 싶으면 `rhwp-advanced` 스킬로.
## Inputs
- 입력 HWP / HWPX 경로 (절대 또는 상대)
- 출력 HWP 경로 (항상 별도 파일. 원본을 덮어쓰지 않는다.)
- 편집 좌표: `--section N --paragraph N --offset N`
- 표 좌표: `--section N --parent-paragraph N --control N --cell N [--cell-paragraph N]`
- 텍스트/쿼리: `--text "..."`, `--query "..."`, `--replacement "..."`
- `create-table`: `--rows N --cols N`
- 선택 플래그: `--case-sensitive`, `--no-replace` (`set-cell-text` 에서 기존 셀 내용 보존), `--format svg|html` (`render`)
## Routing policy
| 작업 | 기본 경로 |
| --- | --- |
| 본문 문단에 텍스트 삽입 | `k-skill-rhwp insert-text` |
| 본문 문단에서 텍스트 삭제 | `k-skill-rhwp delete-text` |
| 단순 전체 치환(같은 서식 유지, **본문 문단만**) | `k-skill-rhwp replace-all --query ... --replacement ...` |
| 치환 대상 위치 사전 조회(**본문 문단만**) | `k-skill-rhwp search --query ... --from-section N --from-paragraph N` |
| 표 셀 안의 텍스트 확인 | `k-skill-rhwp list-paragraphs` + 셀 좌표 확인 후 `k-skill-rhwp set-cell-text` 로 직접 쓰기 |
| 빈 표 삽입 | `k-skill-rhwp create-table --rows N --cols N` |
| 표 셀 내용 교체/채우기 | `k-skill-rhwp set-cell-text --control N --cell N --text "..."` |
| 빈 HWP 생성 | `k-skill-rhwp create-blank <output.hwp>` |
| 구조 파악(섹션/문단 수·길이) | `k-skill-rhwp info <file>` / `list-paragraphs` |
| 페이지 SVG/HTML 미리보기 | `k-skill-rhwp render <file> --page N --format svg` |
모든 편집 서브커맨드는 결과를 JSON 한 줄(CLI에서는 pretty-print)로 돌려준다. `ok: true`, 새 커서 위치(`charOffset`, `paraIdx`, `controlIdx`),
저장된 바이트 수(`bytesWritten`), 출력 경로(`outputPath`) 를 포함한다.
## Workflow
1. **입력 점검**: `k-skill-rhwp info <input>``sourceFormat`(hwp/hwpx), `sectionCount`, 섹션별 `paragraphCount`, 문단별 `length` 를 먼저 확인한다. 편집 좌표는 이 결과에서 뽑는다.
2. **검색이 필요한 경우**: `k-skill-rhwp search <input> --query "2025"` 로 섹션/문단/문자 오프셋을 먼저 얻고, 편집 명령에 그대로 넣는다.
3. **편집**: 아래 예시 중 해당하는 서브커맨드 하나로 실행한다. `--output` 은 항상 원본과 다른 경로를 지정한다.
```bash
# 빈 문서 만들기
npx k-skill-rhwp create-blank ./out/blank.hwp
# 본문 첫 문단 앞에 제목 삽입
npx k-skill-rhwp insert-text ./in.hwp ./out/with-title.hwp \
--section 0 --paragraph 0 --offset 0 \
--text "2026년 오픈소스 AI·SW 지원사업 신청서"
# 2025 → 2026 일괄 치환
npx k-skill-rhwp replace-all ./in.hwp ./out/2026.hwp \
--query 2025 --replacement 2026
# 3행 4열 표 삽입(본문 2번째 문단 끝)
npx k-skill-rhwp create-table ./in.hwp ./out/with-table.hwp \
--section 0 --paragraph 1 --offset 0 --rows 3 --cols 4
# 방금 만든 표의 (0,0) 셀에 "합계" 삽입
# - create-table 결과의 paraIdx / controlIdx 를 그대로 재사용
npx k-skill-rhwp set-cell-text ./out/with-table.hwp ./out/with-cell.hwp \
--section 0 --parent-paragraph <paraIdx> --control <controlIdx> \
--cell 0 --text "합계"
```
4. **round-trip 검증**: 편집 직후 `k-skill-rhwp info <output>` 를 다시 호출하고, 기대한 `paragraphs[].length` 또는 `paragraphCount` 변화를 직접 눈으로 확인한다.
필요하면 `k-skill-rhwp render <output> --page 0 --format html` 로 첫 페이지 렌더 문자열이 생성되는지 sanity check 한다.
5. **민감 원본 보호**: 편집 대상이 개인정보/사업 신청서 등 비공개 문서라면 생성 파일을 레포에 커밋하지 않고, 로그에 남길 때도 본문을 요약·마스킹한다.
## Node API (선택)
CLI가 아니라 Node 코드에서 직접 편집하고 싶으면 같은 패키지를 라이브러리로 쓴다.
```js
const { insertText, getDocumentInfo } = require("k-skill-rhwp");
await insertText({
input: "./in.hwp",
output: "./out.hwp",
section: 0,
paragraph: 0,
offset: 0,
text: "안녕하세요"
});
console.log(await getDocumentInfo("./out.hwp"));
```
Node 18+, `@rhwp/core` WASM 은 첫 호출 시 한 번만 초기화된다. WASM 이 요구하는 `globalThis.measureTextWidth` 콜백은 자동 shim 되므로 별도 설정 없이 돌아간다(정밀 레이아웃이 필요하면 `node-canvas` 기반 shim을 먼저 주입한다).
## Verify outputs after every run
- `ok === true`, `bytesWritten` 가 수 KB 이상.
- `info` 재호출 결과에서 섹션/문단 수·길이 변화가 의도와 일치.
- 표 삽입의 경우 `paraIdx`/`controlIdx` 가 다음 `set-cell-text` 호출에 그대로 들어간다.
- 출력 파일이 원본과 다른 경로이며 원본은 그대로다.
## Done when
- 사용자가 요청한 편집이 HWP 바이너리에 반영되어 새 파일로 저장됐다.
- `k-skill-rhwp info <output>` 가 같은 혹은 늘어난 `sectionCount`/`paragraphCount` 와 기대 `length` 를 돌려준다.
- 원본 파일은 건드리지 않았다.
## Failure modes
- **HWPX 원본 저장 불가(rhwp #196)**: HWPX → HWPX round-trip 은 upstream에서 비활성화 상태다. HWPX 입력이라도 출력은 HWP로만 저장된다. 원본 확장자에 의존하지 말고 항상 `.hwp` 로 저장한다.
- **좌표 범위 초과**: `section/paragraph/offset` 이 실제 문서 범위를 벗어나면 WASM에서 `렌더링 오류: 구역 인덱스 0 범위 초과` 같은 에러를 던지고 CLI는 exit code 1 + stderr 에 메시지를 찍는다. 편집 전에 `info` 로 좌표를 확인한다.
- **복잡한 표·이미지·양식 필드 round-trip**: 현재 업스트림 rhwp v0.7.x 는 베타다. 복잡한 표·이미지·차트·양식필드가 많은 실제 사업 신청서를 HWP round-trip 할 경우 드물게 형식 손실이 발생할 수 있다. round-trip 이 끝나면 `k-skill-rhwp render <output>` + 육안 확인을 권장한다.
- **배포용(읽기전용) 문서**: rhwp 자체는 `convertToEditable` 로 잠금 해제를 지원하지만 `k-skill-rhwp` CLI 서브커맨드는 아직 노출하지 않는다. 필요하면 `rhwp-advanced` 스킬의 업스트림 `rhwp convert` 경로를 쓴다.
- **WASM 초기화**: `@rhwp/core` 번들 WASM(~4 MB) 은 최초 호출 시 한 번 파싱한다. 첫 호출은 수십 ms~수백 ms 지연될 수 있다.
- **파일 인코딩**: 한국어 텍스트는 UTF-8 로 그대로 CLI 에 넘기면 된다. 셸에서 인용부호가 깨질 경우 `--text=$'...'` 같은 형식을 쓴다.
- **`search` / `replace-all` 은 본문 문단만 스캔한다**: 업스트림 `searchText` 가 본문(body) 범위로 제한되어 있고, `k-skill-rhwp replace-all` 도 같은 스코프를 그대로 따른다. **표(cell) 안의 텍스트, 머리말/꼬리말, 각주 본문**에서는 `search``found:false` 를 돌려주고 `replace-all` 도 해당 위치를 건드리지 않는다. 셀 내용이 대상이라면 `list-paragraphs` 또는 `info` 로 표 좌표를 잡고 `set-cell-text` 로 직접 쓴다.
- **문단 경계 / 개행 치환 금지**: `replace-all` 은 한 문단 안에서의 치환만 보장한다. `--replacement` 에 개행(`\n`, `\r`, U+2028, U+2029) 이 들어오면 CLI 는 exit code 1 과 "replacement must not contain newline or paragraph-break characters" 메시지를 돌려준다. 여러 문단을 만들고 싶으면 `insert-text` 를 여러 번 호출한다.
- **치환은 원본 매칭 기준 non-overlapping**: 예를 들어 query `a` / replacement `aa` / 원본 `aaa` 는 원본의 각 `a` 를 한 번씩 교체해 `aaaaaa` 가 된다. 치환으로 새로 들어온 문자열은 다시 매칭하지 않는다.
- **대소문자 무시 매칭은 UTF-16 길이가 보존되는 문자에만 안전하다**: 기본값인 대소문자 무시(`--case-sensitive` 없이) 모드는 `String.prototype.toLowerCase()` 가 UTF-16 길이를 그대로 유지한다는 전제 위에서 오프셋을 계산한다. 터키어 `İ`(U+0130) 처럼 소문자화 시 `i` + 결합 점(U+0307) 로 길이가 늘어나는 문자가 본문 또는 쿼리에 포함되면, 조용한 문서 손상을 방지하기 위해 `replace-all` 이 exit code 1 과 함께 `case-insensitive matching is unsafe because case folding changes the UTF-16 length` 메시지를 돌려준다. 이런 문서에는 `--case-sensitive` 로 다시 실행하거나, 입력을 미리 정규화한다. 한글·ASCII 본문에는 해당하지 않으며, `2025 → 2026` 같은 실제 사업 신청서 워크플로우는 아무 영향을 받지 않는다.
## Notes
- 업스트림 rhwp: https://github.com/edwardkim/rhwp
- 업스트림 `@rhwp/core` npm: https://www.npmjs.com/package/@rhwp/core
- 업스트림은 활발히 개발 중이다(v0.7.3 2026-04-19 기준). breaking change 가능성을 고려해 `k-skill-rhwp` dependency 는 semver caret 으로 고정한다.
- 이 스킬은 **편집 전용** 스킬이다. 조회/변환은 `hwp`, 고급 디버깅은 `rhwp-advanced` 가 담당한다.

View file

@ -207,8 +207,8 @@ test("hwp docs match the published kordoc install and runtime contract", () => {
assert.match(install, /npm install -g kordoc pdfjs-dist /);
assert.match(install, /HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm install kordoc pdfjs-dist` 후 실행/);
assert.match(install, /`kordoc` CLI를 일회성으로만 쓸 때는 `npx --yes --package kordoc --package pdfjs-dist kordoc \.\.\.` 형태를 사용한다\./);
assert.match(readme, /\| HWP 문서 처리 \| .*양식 필드 추출.*Markdown→HWPX 역변환/);
assert.doesNotMatch(readme, /\| HWP 문서 처리 \| .*양식 채우기/);
assert.match(readme, /\| HWP 문서 조회\/변환 \| .*양식 필드 추출.*Markdown→HWPX 역변환/);
assert.doesNotMatch(readme, /\| HWP 문서 조회\/변환 \| .*양식 채우기/);
assert.match(sources, /kordoc/);
assert.match(sources, /pdfjs-dist/);
});
@ -220,8 +220,8 @@ test("repository docs advertise the hwp skill", () => {
const featureDoc = read(path.join("docs", "features", "hwp.md"));
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hwp.md to exist");
assert.match(readme, /\| HWP 문서 처리 \|/);
assert.match(readme, /\[HWP 문서 처리\]\(docs\/features\/hwp\.md\)/);
assert.match(readme, /\| HWP 문서 조회\/변환 \|/);
assert.match(readme, /\[HWP 문서 처리 가이드\]\(docs\/features\/hwp\.md\)/);
assert.match(install, /--skill hwp/);
assert.match(featureDoc, /\bkordoc\b/);
assert.doesNotMatch(featureDoc, /@ohah\/hwpjs/);
@ -2892,3 +2892,109 @@ test("korean-privacy-terms feature doc documents the thin-wrapper install flow a
assert.match(featureDoc, /2026\.9\.11/);
assert.match(featureDoc, /Next\.js/);
});
test("rhwp-edit skill pins the k-skill-rhwp CLI as the editing engine and disclaims kordoc/rhwp-advanced routing", () => {
const skill = read(path.join("rhwp-edit", "SKILL.md"));
assert.match(skill, /^---\nname: rhwp-edit\n/);
assert.match(skill, /k-skill-rhwp/);
assert.match(skill, /@rhwp\/core/);
assert.match(skill, /hwp\/SKILL\.md/);
assert.match(skill, /rhwp-advanced\/SKILL\.md/);
assert.match(skill, /insert-text/);
assert.match(skill, /delete-text/);
assert.match(skill, /replace-all/);
assert.match(skill, /create-table/);
assert.match(skill, /set-cell-text/);
assert.match(skill, /create-blank/);
assert.match(skill, /#196/);
assert.match(skill, /본문 문단만/, "SKILL.md must document body-only scope for search/replace-all");
assert.match(skill, /set-cell-text/, "SKILL.md must reference set-cell-text for cell content workflow");
assert.match(skill, /non-overlapping|개행|문단 경계/, "SKILL.md must document replace-all edge cases");
assert.match(
skill,
/UTF-?16|U\+0130|İ|case[ -]?fold/i,
"SKILL.md must disclose the case-insensitive UTF-16 length-drift guard (Unicode follow-up)"
);
});
test("rhwp-advanced skill pins the upstream rhwp Rust CLI debug/dump/convert surface", () => {
const skill = read(path.join("rhwp-advanced", "SKILL.md"));
assert.match(skill, /^---\nname: rhwp-advanced\n/);
assert.match(skill, /cargo install rhwp/);
assert.match(skill, /export-svg/);
assert.match(skill, /--debug-overlay/);
assert.match(skill, /\brhwp dump\b/);
assert.match(skill, /dump-pages/);
assert.match(skill, /ir-diff/);
assert.match(skill, /thumbnail/);
assert.match(skill, /\brhwp convert\b/);
assert.match(skill, /편집 서브커맨드[가는]? (없다|부재|제공하지 않는다|않는다)/);
assert.match(skill, /rhwp-edit/);
});
test("rhwp feature docs, README, install, roadmap, and sources are wired for the new skills", () => {
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 editDoc = read(path.join("docs", "features", "rhwp-edit.md"));
const advancedDoc = read(path.join("docs", "features", "rhwp-advanced.md"));
assert.match(readme, /\| HWP 문서 편집 \|/);
assert.match(readme, /\| HWP 레이아웃·IR 디버깅 \|/);
assert.match(readme, /\[HWP 문서 편집\]\(docs\/features\/rhwp-edit\.md\)/);
assert.match(readme, /\[HWP 레이아웃·IR 디버깅\]\(docs\/features\/rhwp-advanced\.md\)/);
assert.match(install, /--skill rhwp-edit/);
assert.match(install, /--skill rhwp-advanced/);
assert.match(roadmap, /rhwp-edit/);
assert.match(roadmap, /rhwp-advanced/);
assert.match(roadmap, /#155/);
assert.match(sources, /edwardkim\/rhwp/);
assert.match(sources, /@rhwp\/core/);
assert.match(sources, /issues\/196/);
assert.match(editDoc, /k-skill-rhwp/);
assert.match(editDoc, /insert-text/);
assert.match(editDoc, /create-table/);
assert.match(editDoc, /#196/);
assert.match(
editDoc,
/본문\S* 문단만|본문 \(body\) 문단만|body paragraphs only/,
"rhwp-edit feature doc must disclose search/replace-all body-only scope"
);
assert.match(
editDoc,
/UTF-?16|U\+0130|İ|case[ -]?fold/i,
"rhwp-edit feature doc must disclose the case-insensitive UTF-16 length-drift guard (Unicode follow-up)"
);
assert.match(advancedDoc, /cargo install rhwp/);
assert.match(advancedDoc, /export-svg/);
assert.match(advancedDoc, /ir-diff/);
assert.match(advancedDoc, /편집/);
});
test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver changeset", () => {
const packagePath = path.join(repoRoot, "packages", "k-skill-rhwp", "package.json");
assert.ok(fs.existsSync(packagePath), "expected packages/k-skill-rhwp/package.json");
const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
assert.equal(pkg.name, "k-skill-rhwp");
assert.ok(pkg.bin && pkg.bin["k-skill-rhwp"] === "bin/k-skill-rhwp.js", "expected bin mapping");
assert.ok(pkg.dependencies && pkg.dependencies["@rhwp/core"], "expected @rhwp/core dependency");
assert.ok(pkg.engines && /\^|>=\s*1[89]/.test(pkg.engines.node || ""), "expected Node 18+");
assert.ok(
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "src", "wasm-init.js")),
"expected src/wasm-init.js"
);
assert.ok(
fs.existsSync(path.join(repoRoot, "packages", "k-skill-rhwp", "bin", "k-skill-rhwp.js")),
"expected bin/k-skill-rhwp.js"
);
});

View file

@ -0,0 +1,818 @@
"""Tests for korean-slang-writing skill (slang_search + slang_lookup)."""
from __future__ import annotations
import importlib.util
import io
import json
import pathlib
import sys
import unittest
import urllib.parse
from unittest import mock
SKILL_ROOT = pathlib.Path(__file__).resolve().parents[1] / "korean-slang-writing"
SCRIPTS_DIR = SKILL_ROOT / "scripts"
DATA_DIR = SKILL_ROOT / "data"
def _load(module_name: str, script_name: str):
path = SCRIPTS_DIR / script_name
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load spec for {path}")
module = importlib.util.module_from_spec(spec)
# The skill's scripts/ must be on sys.path so sibling modules like
# _slang_http can be imported when tests exec each module in isolation.
script_parent = str(SCRIPTS_DIR)
if script_parent not in sys.path:
sys.path.insert(0, script_parent)
# Register under file-stem name too so intra-skill imports
# (e.g. slang_lookup -> _slang_http) share the same module object
# and exception classes compare identical across test boundaries.
sys.modules[path.stem] = module
spec.loader.exec_module(module)
return module
slang_http = _load("korean_slang_writing_http", "_slang_http.py")
slang_search = _load("korean_slang_writing_search", "slang_search.py")
slang_lookup = _load("korean_slang_writing_lookup", "slang_lookup.py")
def make_entry(
*,
term: str,
aliases=None,
meaning_short: str = "meaning",
usage_context=None,
mood_tags=None,
intensity: str = "medium",
safety: str = "safe",
example_usage=None,
namuwiki_url: str = "https://namu.wiki/w/test",
era: str = "2020",
still_usable: bool = True,
) -> dict:
return {
"term": term,
"aliases": list(aliases or []),
"meaning_short": meaning_short,
"usage_context": list(usage_context or []),
"mood_tags": list(mood_tags or []),
"intensity": intensity,
"safety": safety,
"example_usage": list(example_usage or []),
"namuwiki_url": namuwiki_url,
"era": era,
"still_usable": still_usable,
}
def make_index(entries: list[dict]) -> dict:
return {
"schema_version": "1.0",
"source": "test-fixture",
"last_reviewed": "2026-04-22",
"notes": "fixture for tests",
"entries": entries,
}
class SeedIndexShapeTest(unittest.TestCase):
def setUp(self) -> None:
self.seed_path = DATA_DIR / "seed-slang.json"
with self.seed_path.open(encoding="utf-8") as fh:
self.seed = json.load(fh)
def test_seed_is_a_dict_with_entries_array(self) -> None:
self.assertIsInstance(self.seed, dict)
self.assertIn("entries", self.seed)
self.assertIsInstance(self.seed["entries"], list)
self.assertGreaterEqual(len(self.seed["entries"]), 20)
def test_each_entry_has_required_fields(self) -> None:
required = {
"term",
"aliases",
"meaning_short",
"usage_context",
"mood_tags",
"intensity",
"safety",
"example_usage",
"namuwiki_url",
"era",
"still_usable",
}
for entry in self.seed["entries"]:
missing = required - set(entry.keys())
self.assertFalse(missing, f"{entry.get('term')} missing {missing}")
self.assertIsInstance(entry["aliases"], list)
self.assertIsInstance(entry["usage_context"], list)
self.assertIsInstance(entry["mood_tags"], list)
self.assertIsInstance(entry["example_usage"], list)
self.assertIn(entry["intensity"], {"subtle", "medium", "strong"})
self.assertIn(entry["safety"], {"safe", "spicy", "risky"})
self.assertTrue(entry["namuwiki_url"].startswith("https://namu.wiki/"))
def test_no_risky_safety_in_v1_seed(self) -> None:
risky = [e["term"] for e in self.seed["entries"] if e["safety"] == "risky"]
self.assertEqual(risky, [], "v1 seed must exclude risky-safety entries")
def test_each_seed_url_decodes_to_term_or_alias(self) -> None:
"""Regression guard: every seed namuwiki_url must decode to either the
entry's term or one of its aliases.
This catches URL-encoding bugs (wrong Hangul codepoint, missing receiving
consonant, shortened vowel, etc.) that the mocked lookup tests would never
notice because they replace fetch_page. It does NOT hit the network.
"""
prefix = "https://namu.wiki/w/"
for entry in self.seed["entries"]:
url = entry["namuwiki_url"]
self.assertTrue(
url.startswith(prefix),
f"{entry['term']!r} namuwiki_url must start with {prefix}: got {url!r}",
)
path_segment = url[len(prefix):]
decoded = urllib.parse.unquote(path_segment)
candidates = {entry["term"], *entry.get("aliases", [])}
self.assertIn(
decoded,
candidates,
(
f"{entry['term']!r} namuwiki_url decodes to {decoded!r}, "
f"which is neither the term nor one of its aliases "
f"{sorted(candidates)!r}. Check URL encoding."
),
)
def test_no_seed_entry_points_at_known_missing_namuwiki_page(self) -> None:
"""Regression guard: we dropped entries that had no canonical Namu Wiki page.
Keep them dropped so nobody re-adds a 404-returning URL. Extend this list
only after live-verifying the new URL returns 200.
"""
terms = [e["term"] for e in self.seed["entries"]]
self.assertNotIn(
"당모치",
terms,
"'당모치' has no live Namu Wiki article; do not re-add without a valid URL.",
)
class SearchQueryMatchingTest(unittest.TestCase):
def setUp(self) -> None:
self.index = make_index([
make_entry(term="중꺾마", aliases=["중요한 건 꺾이지 않는 마음"], era="2022"),
make_entry(term="갓생", aliases=["갓생러"], era="2021"),
make_entry(term="럭키비키", aliases=["Lucky Vicky"], era="2024"),
make_entry(term="중꺾그마", aliases=[], era="2023"),
])
def test_exact_term_match_wins_over_substring(self) -> None:
result = slang_search.search(query="중꺾마", index=self.index)
self.assertGreaterEqual(result["total_candidates"], 1)
self.assertEqual(result["candidates"][0]["term"], "중꺾마")
self.assertEqual(result["candidates"][0]["match_reason"], "exact")
def test_alias_match_is_reported_as_alias(self) -> None:
result = slang_search.search(
query="중요한 건 꺾이지 않는 마음", index=self.index
)
self.assertEqual(result["candidates"][0]["term"], "중꺾마")
self.assertEqual(result["candidates"][0]["match_reason"], "alias")
def test_substring_match_finds_partials(self) -> None:
result = slang_search.search(query="", index=self.index)
matched_terms = [c["term"] for c in result["candidates"]]
self.assertIn("중꺾마", matched_terms)
self.assertIn("중꺾그마", matched_terms)
for candidate in result["candidates"]:
if candidate["term"] in {"중꺾마", "중꺾그마"}:
self.assertIn(candidate["match_reason"], {"exact", "substring"})
def test_substring_match_is_case_insensitive_for_english(self) -> None:
result = slang_search.search(query="vicky", index=self.index)
self.assertEqual(result["candidates"][0]["term"], "럭키비키")
def test_exact_match_outranks_substring_match(self) -> None:
index = make_index([
make_entry(term="중꺾그마", era="2023"),
make_entry(term="중꺾마", era="2022"),
])
result = slang_search.search(query="중꺾마", index=index)
reasons = [c["match_reason"] for c in result["candidates"]]
self.assertEqual(result["candidates"][0]["term"], "중꺾마")
self.assertEqual(reasons[0], "exact")
def test_no_query_returns_all_entries_bounded_by_limit(self) -> None:
result = slang_search.search(index=self.index, limit=2)
self.assertEqual(result["total_candidates"], 2)
for candidate in result["candidates"]:
self.assertEqual(candidate["match_reason"], "no-query")
def test_unmatched_query_returns_empty_candidates(self) -> None:
result = slang_search.search(query="없는단어xyz", index=self.index)
self.assertEqual(result["total_candidates"], 0)
self.assertEqual(result["candidates"], [])
class SearchFilterTest(unittest.TestCase):
def setUp(self) -> None:
self.index = make_index([
make_entry(
term="A긍정",
mood_tags=["긍정", "유머"],
usage_context=["SNS", "마케팅"],
safety="safe",
intensity="medium",
era="2022",
),
make_entry(
term="B부정",
mood_tags=["부정"],
usage_context=["일상"],
safety="safe",
intensity="subtle",
era="2021",
),
make_entry(
term="C강한",
mood_tags=["긍정"],
usage_context=["SNS"],
safety="spicy",
intensity="strong",
era="2020",
),
make_entry(
term="D옛것",
mood_tags=["긍정"],
usage_context=["SNS"],
safety="safe",
intensity="medium",
era="2015",
still_usable=False,
),
])
def test_mood_filter_matches_any_of_requested_tags(self) -> None:
result = slang_search.search(mood=["긍정"], index=self.index)
terms = {c["term"] for c in result["candidates"]}
# D옛것 has matching mood but still_usable=false so is excluded by default.
self.assertEqual(terms, {"A긍정", "C강한"})
def test_context_filter_requires_overlap(self) -> None:
result = slang_search.search(context=["마케팅"], index=self.index)
terms = {c["term"] for c in result["candidates"]}
self.assertEqual(terms, {"A긍정"})
def test_safety_single_value_filter(self) -> None:
result = slang_search.search(safety="spicy", index=self.index)
terms = {c["term"] for c in result["candidates"]}
self.assertEqual(terms, {"C강한"})
def test_safety_list_filter_allows_multiple_levels(self) -> None:
result = slang_search.search(safety=["safe", "spicy"], index=self.index)
terms = {c["term"] for c in result["candidates"]}
self.assertEqual(terms, {"A긍정", "B부정", "C강한"})
def test_intensity_filter(self) -> None:
result = slang_search.search(intensity="subtle", index=self.index)
terms = {c["term"] for c in result["candidates"]}
self.assertEqual(terms, {"B부정"})
def test_include_deprecated_flag_brings_back_legacy_entries(self) -> None:
result = slang_search.search(
mood=["긍정"], index=self.index, include_deprecated=True
)
terms = {c["term"] for c in result["candidates"]}
self.assertIn("D옛것", terms)
def test_limit_clamps_results(self) -> None:
result = slang_search.search(mood=["긍정"], index=self.index, limit=1)
self.assertEqual(len(result["candidates"]), 1)
self.assertEqual(result["total_candidates"], 1)
self.assertGreaterEqual(result["matched_before_limit"], 2)
def test_combined_filters_are_anded_together(self) -> None:
result = slang_search.search(
mood=["긍정"],
context=["SNS"],
safety="safe",
index=self.index,
)
terms = {c["term"] for c in result["candidates"]}
self.assertEqual(terms, {"A긍정"})
def test_filters_applied_summary_is_reported(self) -> None:
result = slang_search.search(
mood=["긍정"], safety="safe", limit=5, index=self.index
)
self.assertEqual(result["filters_applied"]["mood"], ["긍정"])
self.assertEqual(result["filters_applied"]["safety"], ["safe"])
self.assertEqual(result["filters_applied"]["limit"], 5)
self.assertFalse(result["filters_applied"]["include_deprecated"])
class SearchCliTest(unittest.TestCase):
def setUp(self) -> None:
self.fixture_path = pathlib.Path(__file__).resolve().parent / "fixtures" / "slang-fixture.json"
self.fixture_path.parent.mkdir(parents=True, exist_ok=True)
fixture = make_index([
make_entry(term="갓생", aliases=["갓생러"], mood_tags=["긍정"], era="2021"),
make_entry(term="현타", mood_tags=["부정"], era="2015"),
])
self.fixture_path.write_text(json.dumps(fixture, ensure_ascii=False), encoding="utf-8")
def tearDown(self) -> None:
if self.fixture_path.exists():
self.fixture_path.unlink()
def test_cli_json_output_contains_candidates(self) -> None:
argv = [
"--query",
"갓생",
"--index-path",
str(self.fixture_path),
"--format",
"json",
]
buf = io.StringIO()
with mock.patch.object(sys, "stdout", buf):
exit_code = slang_search.main(argv)
self.assertEqual(exit_code, 0)
output = json.loads(buf.getvalue())
self.assertEqual(output["candidates"][0]["term"], "갓생")
def test_cli_text_output_is_human_readable(self) -> None:
argv = [
"--query",
"갓생",
"--index-path",
str(self.fixture_path),
"--format",
"text",
]
buf = io.StringIO()
with mock.patch.object(sys, "stdout", buf):
exit_code = slang_search.main(argv)
self.assertEqual(exit_code, 0)
output = buf.getvalue()
self.assertIn("갓생", output)
self.assertIn("긍정", output)
def test_cli_reports_error_when_index_path_invalid(self) -> None:
argv = [
"--query",
"갓생",
"--index-path",
"/nonexistent/does-not-exist.json",
]
err_buf = io.StringIO()
out_buf = io.StringIO()
with mock.patch.object(sys, "stderr", err_buf), mock.patch.object(sys, "stdout", out_buf):
exit_code = slang_search.main(argv)
self.assertNotEqual(exit_code, 0)
self.assertIn("error", err_buf.getvalue().lower())
class LoadIndexTest(unittest.TestCase):
def test_load_index_reads_bundled_seed_by_default(self) -> None:
index = slang_search.load_index()
self.assertIn("entries", index)
self.assertGreaterEqual(len(index["entries"]), 20)
def test_load_index_reads_explicit_path(self) -> None:
path = DATA_DIR / "seed-slang.json"
index = slang_search.load_index(str(path))
self.assertIn("entries", index)
def test_load_index_raises_on_missing_path(self) -> None:
with self.assertRaises(FileNotFoundError):
slang_search.load_index("/nonexistent/seed.json")
class LookupParsingTest(unittest.TestCase):
HTML_SAMPLE = """
<html>
<head><title>중꺾마 - 나무위키</title></head>
<body>
<article>
<div class="wiki-paragraph">
<p>중꺾마는 <b>중요한 꺾이지 않는 마음</b> 줄임말로, 2022 FIFA 월드컵 당시 유행하기 시작한 표현이다.
포기하지 않는 불굴의 의지를 의미한다.</p>
</div>
</article>
</body>
</html>
"""
HTML_CURRENT_NAMUWIKI = """
<!doctype html>
<html>
<head>
<title>중요한 것은 꺾이지 않는 마음 - 나무위키</title>
<meta property="og:description" content="RGE전 패배는 괜찮다. 중요한 것은 꺾이지 않는 마음">
</head>
<body>
<div class="_36R8DWTn">
<h1 class="_2HZC0kyI"><a href="/w/test" class="kPIqc4b-"><span>중요한 것은 꺾이지 않는 마음</span></a></h1>
<div class="RW63SZFE">최근 수정 시각: 2026-03-29 13:14:18</div>
<div class="W6XTddIf">
<span><a href="/star">별표</a></span>
<span><a href="/edit">편집 요청</a></span>
</div>
<h2 class="_sectionHeading"><span>1. 개요</span><a class="edit-link">[편집]</a></h2>
<div class="_sectionBody">
<p>'중요한 것은 꺾이지 않는 마음' 리그 오브 레전드 2022 월드 챔피언십에 참가한 프로게임단
DRX 소속 프로게이머 김혁규(Deft) 선수의 인터뷰를 담은 영상의 제목에서 유래된 유행어다.</p>
<p>포기하지 않는 불굴의 의지를 의미한다.</p>
</div>
<h2 class="_sectionHeading"><span>2. 발생 양상</span><a class="edit-link">[편집]</a></h2>
<div class="_sectionBody">
<p>2022 LoL 월드 챔피언십에서 DRX가 디펜딩 챔피언 T1을 꺾고 우승하며 회자되었다.</p>
</div>
</div>
</body>
</html>
"""
def test_extract_title_strips_namuwiki_suffix(self) -> None:
title = slang_lookup.extract_title(self.HTML_SAMPLE)
self.assertEqual(title, "중꺾마")
def test_extract_summary_returns_first_paragraph_text(self) -> None:
summary = slang_lookup.extract_summary(self.HTML_SAMPLE, max_length=1500)
self.assertIn("꺾이지 않는 마음", summary)
self.assertNotIn("<p>", summary)
self.assertNotIn("<b>", summary)
def test_extract_summary_truncates_to_max_length(self) -> None:
long_html = (
"<html><body><article><p>"
+ ("" * 5000)
+ "</p></article></body></html>"
)
summary = slang_lookup.extract_summary(long_html, max_length=100)
# Summary is capped at max_length + 3 chars for the "..." suffix.
self.assertLessEqual(len(summary), 103)
def test_extract_summary_returns_empty_on_unknown_structure(self) -> None:
summary = slang_lookup.extract_summary("<html><body></body></html>", max_length=1500)
self.assertEqual(summary, "")
def test_extract_summary_uses_h2_section_boundaries_on_current_namuwiki_layout(
self,
) -> None:
"""Must use numbered-h2 anchors when Namu Wiki class names are obfuscated."""
summary = slang_lookup.extract_summary(
self.HTML_CURRENT_NAMUWIKI, max_length=2000
)
self.assertIn("중요한 것은 꺾이지 않는 마음", summary)
self.assertIn("DRX", summary)
self.assertIn("포기하지 않는 불굴의 의지", summary)
self.assertNotIn("T1을 꺾고 우승", summary)
self.assertNotIn("최근 수정 시각", summary)
self.assertNotIn("편집 요청", summary)
self.assertNotIn("별표", summary)
def test_extract_summary_strips_section_heading_edit_affordances(self) -> None:
"""[편집] edit affordances and N. section numbering must not leak through."""
summary = slang_lookup.extract_summary(
self.HTML_CURRENT_NAMUWIKI, max_length=2000
)
self.assertNotIn("[편집]", summary)
self.assertNotIn("1. 개요", summary)
def test_extract_summary_falls_back_to_og_description_when_no_h2_or_classes(
self,
) -> None:
"""og:description is the final structural fallback before giving up."""
html = """
<html>
<head>
<title>럭키비키 - 나무위키</title>
<meta property="og:description" content="완전 럭키비키잖아~! 장원영 IVE 의 멤버 장원영 의 발언에서 유래한 초긍정적 마인드를 표현하는 인터넷 밈.">
</head>
<body>
<div class="obfuscated-x1y2z3">navigation chrome only, no real body.</div>
</body>
</html>
"""
summary = slang_lookup.extract_summary(html, max_length=500)
self.assertIn("럭키비키", summary)
self.assertIn("장원영", summary)
self.assertNotIn("&amp;", summary)
self.assertNotIn("<", summary)
def test_extract_summary_handles_single_h2_page(self) -> None:
"""Single-section pages must still extract body text after the lone h2."""
html = """
<html><head><title>짧은유행어 - 나무위키</title></head>
<body>
<h1>짧은유행어</h1>
<h2>1. 개요[편집]</h2>
<p> 유행어는 짧은 설명을 가진 유행어이다.</p>
<p> 번째 문단도 포함되어야 한다.</p>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("짧은 설명", summary)
self.assertIn("두 번째 문단", summary)
def test_extract_summary_prefers_h2_strategy_over_class_strategy(self) -> None:
"""h2 boundaries must beat MAIN_CONTENT_CLASSES when both are present."""
html = """
<html><head><title>test - 나무위키</title></head>
<body>
<div class="wiki-paragraph">navigation sidebar noise goes here.</div>
<h2>1. 개요[편집]</h2>
<p>정확한 개요 본문입니다.</p>
<h2>2. 상세[편집]</h2>
<p>상세 섹션은 제외되어야 합니다.</p>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("정확한 개요 본문", summary)
self.assertNotIn("navigation sidebar noise", summary)
self.assertNotIn("상세 섹션은 제외되어야 합니다", summary)
def test_extract_summary_ignores_h2_without_numbered_section_prefix(
self,
) -> None:
"""Sidebar/nav ``<h2>`` widgets without a numbered section prefix
(``<h2>관련 문서</h2>``, ``<h2>외부 링크</h2>`` etc.) MUST NOT be treated
as section boundaries. When no numbered h2 is present, the extractor
falls through to the class-based tier.
"""
html = """
<html><head><title>test - 나무위키</title></head>
<body>
<h2>관련 문서</h2>
<div class="navigation-sidebar-chrome">unrelated sidebar body</div>
<h2>바로가기</h2>
<div class="wiki-paragraph">
<p>실제 본문은 여기에 있습니다.</p>
</div>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("실제 본문", summary)
self.assertNotIn("unrelated sidebar body", summary)
self.assertNotIn("관련 문서", summary)
self.assertNotIn("바로가기", summary)
def test_extract_summary_numbered_h2_gate_skips_sidebar_h2_before_section_one(
self,
) -> None:
"""Regression for the reviewer-flagged edge case: a sidebar-style
``<h2>관련 문서</h2>`` placed BEFORE the section ``<h2>1. 개요</h2>``
must not anchor the extractor. Only numbered section headers
(``\\d+(?:\\.\\d+)*\\.\\s``) can act as section boundaries.
"""
html = """
<html><head><title>test - 나무위키</title></head>
<body>
<h2>관련 문서</h2>
<ul><li>link1</li><li>link2</li></ul>
<h2>1. 개요[편집]</h2>
<p>진짜 개요 본문입니다.</p>
<h2>2. 상세[편집]</h2>
<p>상세 섹션은 제외됩니다.</p>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("진짜 개요 본문", summary)
self.assertNotIn("link1", summary)
self.assertNotIn("link2", summary)
self.assertNotIn("상세 섹션은 제외됩니다", summary)
def test_extract_summary_strips_category_nav_template_markers(self) -> None:
"""Namu Wiki inline category nav templates render as
``[펼치기 · 접기] item · item · item`` inline on one line. The marker
itself AND the trailing category items on the same line (its "aftermath")
must both be stripped so the agent sees the real prose.
"""
html = """
<html><head><title>꿀잼 - 나무위키</title></head>
<body>
<h2>1. 개요[편집]</h2>
<p>문화 유행어 [펼치기 · 접기] 모음 (ㄱ항목 · ㄴ항목 · 꿀잼 · ㄹ항목)</p>
<p>꿀잼은 '' '재미' 합성어로, 정말 재미있을 사용하는 유행어이다.</p>
<h2>2. 상세[편집]</h2>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertNotIn("[펼치기 · 접기]", summary)
self.assertNotIn("ㄱ항목", summary)
self.assertNotIn("ㄹ항목", summary)
self.assertNotIn("밈 모음", summary)
self.assertIn("꿀잼은", summary)
self.assertIn("재미있을 때", summary)
def test_extract_summary_category_nav_strip_preserves_surrounding_content(
self,
) -> None:
"""Category-nav stripping must only affect the marker-containing line.
Content on *other* lines (both before and after) must be preserved.
"""
html = """
<html><head><title>test - 나무위키</title></head>
<body>
<h2>1. 개요[편집]</h2>
<p>도입문입니다. 중요한 소개 문장.</p>
<p>분류 [펼치기 · 접기] 카테고리A · 카테고리B · 카테고리C</p>
<p> 문단은 반드시 보존되어야 합니다.</p>
<h2>2. 상세[편집]</h2>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("도입문입니다", summary)
self.assertIn("중요한 소개 문장", summary)
self.assertIn("이 문단은 반드시 보존", summary)
self.assertNotIn("[펼치기 · 접기]", summary)
self.assertNotIn("카테고리A", summary)
self.assertNotIn("카테고리C", summary)
def test_extract_summary_strips_details_block_wrapping_pelchigi_summary(
self,
) -> None:
"""Live Namu Wiki wraps category-nav templates in a ``<details>`` block
whose ``<summary>`` label is ``[펼치기 · 접기]``. The entire ``<details>``
block (summary + all its body rows/cells) must be stripped, not just
the marker line, so multi-line category dumps don't survive into the
agent-visible summary.
"""
html = """
<html><head><title>꿀잼 - 나무위키</title></head>
<body>
<h2>1. 개요[편집]</h2>
<div class="nav-wrapper">
<details class="cat-nav">
<summary>[펼치기 · 접기]</summary>
<div>문화 유행어</div>
<div>기타</div>
<div>item1 · item2 · item3</div>
<div></div>
<div>가놈 · 가성비 댓글</div>
</details>
</div>
<p>무언가가 매우 재미있다는 의미의 인터넷 유행어이다.</p>
<h2>2. 상세[편집]</h2>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertNotIn("[펼치기 · 접기]", summary)
self.assertNotIn("문화 및 유행어", summary)
self.assertNotIn("item1", summary)
self.assertNotIn("가놈", summary)
self.assertNotIn("가성비 댓글", summary)
self.assertIn("매우 재미있다는 의미", summary)
self.assertIn("인터넷 유행어", summary)
def test_extract_summary_keeps_details_block_without_pelchigi_summary(
self,
) -> None:
"""``<details>`` blocks whose ``<summary>`` does NOT contain ``펼치기``
(e.g. spoilers, footnotes) must be preserved only the specific
category-nav pattern is stripped.
"""
html = """
<html><head><title>test - 나무위키</title></head>
<body>
<h2>1. 개요[편집]</h2>
<details>
<summary>스포일러 주의</summary>
<p> 내용은 스포일러 정보를 포함합니다.</p>
</details>
<p>일반 본문도 있습니다.</p>
<h2>2. 상세[편집]</h2>
</body></html>
"""
summary = slang_lookup.extract_summary(html, max_length=2000)
self.assertIn("스포일러", summary)
self.assertIn("일반 본문", summary)
class LookupNetworkTest(unittest.TestCase):
def test_lookup_returns_structured_result_on_success(self) -> None:
html = LookupParsingTest.HTML_SAMPLE
expected_url = slang_http.build_namuwiki_url("중꺾마")
def fake_fetch(url: str, timeout: int):
self.assertEqual(url, expected_url)
return html
with mock.patch.object(slang_lookup, "fetch_page", side_effect=fake_fetch):
result = slang_lookup.lookup(
term_or_url=expected_url,
timeout=15,
max_length=1500,
)
self.assertTrue(result["fetched"])
self.assertEqual(result["title"], "중꺾마")
self.assertIn("꺾이지 않는 마음", result["summary"])
self.assertEqual(result["url"], expected_url)
decoded_path = urllib.parse.unquote(expected_url.rsplit("/", 1)[-1])
self.assertEqual(decoded_path, "중꺾마")
def test_lookup_handles_http_403_as_blocked(self) -> None:
def fake_fetch(url: str, timeout: int):
raise slang_http.BlockedError("HTTP 403 (possibly Cloudflare)")
with mock.patch.object(slang_lookup, "fetch_page", side_effect=fake_fetch):
result = slang_lookup.lookup(
term_or_url="https://namu.wiki/w/test", timeout=5, max_length=1500
)
self.assertFalse(result["fetched"])
self.assertEqual(result["block_reason"], "blocked")
self.assertIn("403", result["error"])
self.assertEqual(result["summary"], "")
def test_lookup_handles_http_404_gracefully(self) -> None:
def fake_fetch(url: str, timeout: int):
raise slang_http.NotFoundError("HTTP 404: page not found")
with mock.patch.object(slang_lookup, "fetch_page", side_effect=fake_fetch):
result = slang_lookup.lookup(
term_or_url="https://namu.wiki/w/test", timeout=5, max_length=1500
)
self.assertFalse(result["fetched"])
self.assertEqual(result["block_reason"], "not_found")
def test_lookup_accepts_bare_term_and_builds_namuwiki_url(self) -> None:
captured: dict[str, str] = {}
def fake_fetch(url: str, timeout: int):
captured["url"] = url
return LookupParsingTest.HTML_SAMPLE
with mock.patch.object(slang_lookup, "fetch_page", side_effect=fake_fetch):
result = slang_lookup.lookup(
term_or_url="중꺾마", timeout=10, max_length=500
)
self.assertTrue(captured["url"].startswith("https://namu.wiki/w/"))
# Korean multi-byte title must be percent-encoded for namuwiki URL safety.
self.assertIn("%", captured["url"])
self.assertEqual(result["title"], "중꺾마")
class HttpUtilitiesTest(unittest.TestCase):
def test_build_namuwiki_url_encodes_korean_title(self) -> None:
url = slang_http.build_namuwiki_url("중꺾마")
self.assertTrue(url.startswith("https://namu.wiki/w/"))
expected_suffix = urllib.parse.quote("중꺾마", safe="/")
self.assertEqual(url, f"https://namu.wiki/w/{expected_suffix}")
self.assertIn("%", url)
def test_build_namuwiki_url_leaves_existing_url_alone(self) -> None:
existing = "https://namu.wiki/w/%ED%85%8C%EC%8A%A4%ED%8A%B8"
self.assertEqual(slang_http.build_namuwiki_url(existing), existing)
def test_is_namuwiki_url_detects_namuwiki(self) -> None:
self.assertTrue(slang_http.is_namuwiki_url("https://namu.wiki/w/test"))
self.assertTrue(slang_http.is_namuwiki_url("https://en.namu.wiki/w/test"))
self.assertFalse(slang_http.is_namuwiki_url("https://example.com/test"))
class LookupCliTest(unittest.TestCase):
def test_cli_json_output(self) -> None:
with mock.patch.object(slang_lookup, "fetch_page", return_value=LookupParsingTest.HTML_SAMPLE):
argv = [
"중꺾마",
"--format",
"json",
"--max-length",
"500",
]
buf = io.StringIO()
with mock.patch.object(sys, "stdout", buf):
exit_code = slang_lookup.main(argv)
self.assertEqual(exit_code, 0)
output = json.loads(buf.getvalue())
self.assertEqual(output["title"], "중꺾마")
self.assertTrue(output["fetched"])
def test_cli_exits_non_zero_when_blocked(self) -> None:
def raise_blocked(url: str, timeout: int):
raise slang_http.BlockedError("HTTP 403")
with mock.patch.object(slang_lookup, "fetch_page", side_effect=raise_blocked):
argv = ["https://namu.wiki/w/test"]
out_buf = io.StringIO()
err_buf = io.StringIO()
with mock.patch.object(sys, "stdout", out_buf), mock.patch.object(sys, "stderr", err_buf):
exit_code = slang_lookup.main(argv)
self.assertEqual(exit_code, 2)
output = json.loads(out_buf.getvalue())
self.assertFalse(output["fetched"])
if __name__ == "__main__":
unittest.main()