mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Sync dev → main: scholarship, public restroom, KBL, Hola Poke + HWP/stock proxy upgrades (#136)
* 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 --------- Co-authored-by: minsing-jin <ironman0722@naver.com>
This commit is contained in:
parent
0ec0ab929e
commit
68e6829052
58 changed files with 8401 additions and 206 deletions
5
.changeset/bright-penguins-tickle.md
Normal file
5
.changeset/bright-penguins-tickle.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"public-restroom-nearby": minor
|
||||
---
|
||||
|
||||
Add the first official public-restroom nearby lookup package and skill/docs set.
|
||||
5
.changeset/issue-129-kbl-results.md
Normal file
5
.changeset/issue-129-kbl-results.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kbl-results": minor
|
||||
---
|
||||
|
||||
Add a reusable KBL results and standings package backed by the official JSON APIs.
|
||||
10
README.md
10
README.md
|
|
@ -31,6 +31,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
|
||||
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
|
||||
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
|
||||
| 장학금 검색 및 조회 | 한국장학재단·전국 대학교·재단·기업 장학 공고를 검색해 금액·자격·지원구간·링크를 정리하고 KST 기준 현재 날짜 마감 상태와 조건별 필터링까지 제공 | 불필요 | [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md) |
|
||||
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
|
||||
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
|
||||
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
|
||||
|
|
@ -39,19 +40,22 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
|
||||
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
|
||||
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
|
||||
| 근처 공중화장실 찾기 | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| KBL 경기 결과 조회 | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
| LCK 경기 분석 | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
|
||||
| 토스증권 조회 | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
|
||||
| 하이패스 영수증 발급 | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
|
||||
| 로또 당첨 확인 | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
|
||||
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||
| HWP 문서 처리 | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
||||
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
|
||||
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
||||
| 다이소 상품 조회 | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
|
||||
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
|
||||
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
|
||||
| 올라포케 역삼 포케 | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
|
||||
| 택배 배송조회 | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
|
||||
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
|
||||
| 번개장터 검색 | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
|
||||
|
|
@ -101,6 +105,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
|
||||
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
|
||||
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
|
||||
- [장학금 검색 및 조회 가이드](docs/features/korean-scholarship-search.md)
|
||||
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
|
||||
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
|
||||
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
|
||||
|
|
@ -109,7 +114,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
|
||||
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
|
||||
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)
|
||||
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
|
||||
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
|
||||
- [토스증권 조회 가이드](docs/features/toss-securities.md)
|
||||
|
|
@ -122,6 +129,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [다이소 상품 조회](docs/features/daiso-product-search.md)
|
||||
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
|
||||
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
|
||||
- [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md)
|
||||
- [택배 배송조회](docs/features/delivery-tracking.md)
|
||||
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
|
||||
- [번개장터 검색 가이드](docs/features/bunjang-search.md)
|
||||
|
|
|
|||
266
docs/features/hola-poke-yeoksam.md
Normal file
266
docs/features/hola-poke-yeoksam.md
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
# 올라포케 역삼 포케 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 올라포케 역삼점 메뉴 조회 (`get_menu`)
|
||||
- 위치·영업시간·배달 반경·단체주문 링크 조회 (`get_shop_info`)
|
||||
- 즉석 래플형 이벤트 참여 (`enter_event`)
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
이 기능은 원본 [`mnspkm/hola-poke-yeoksam-skill`](https://github.com/mnspkm/hola-poke-yeoksam-skill) 이 연결하는 **remote MCP server** 를 그대로 사용한다.
|
||||
`k-skill` 안에 별도 수집기나 프록시를 추가하지 않고, skill/docs 가이드만 유지한다.
|
||||
|
||||
즉 기본 전제는 아래 endpoint 가 MCP client 에 등록돼 있어야 한다.
|
||||
|
||||
- `https://hola-poke-yeoksam-skill.onrender.com/mcp`
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- MCP client (Claude Desktop, Cursor, Codex 등)
|
||||
- 필요하면 `npx` (`mcp-remote` 경유 stdio 브리지용)
|
||||
- 이벤트 참여 시 사용자 휴대폰 번호 (`01012345678` 또는 `010-1234-5678`)
|
||||
|
||||
## 빠른 연결 예시
|
||||
|
||||
### Claude Desktop (`mcp-remote` 경유)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hola-poke-yeoksam": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-remote", "https://hola-poke-yeoksam-skill.onrender.com/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor / HTTP MCP
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hola-poke-yeoksam": {
|
||||
"url": "https://hola-poke-yeoksam-skill.onrender.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
### 1. 메뉴 탐색
|
||||
|
||||
- 사용자가 추천/메뉴를 물으면 `get_menu()` 를 호출한다.
|
||||
- 포케, 사이드, 세트, 토핑 구조를 보고 핵심 메뉴와 가격을 짧게 요약한다.
|
||||
- 정확한 보상/프로모션 문구는 메뉴 정보와 섞어 임의로 꾸미지 않는다.
|
||||
|
||||
### 2. 매장 정보 조회
|
||||
|
||||
- 위치, 영업시간, 배달 반경, 단체주문 문의는 `get_shop_info()` 를 호출한다.
|
||||
- 주소, 영업시간, 배달 가능 범위, `group_order_url` 을 우선 전달한다.
|
||||
|
||||
### 3. 이벤트 참여
|
||||
|
||||
현재 문서 기준 스킴은 **즉석 래플** 이다.
|
||||
|
||||
1. 사용자가 참여 의사를 밝히면 번호를 먼저 받는다.
|
||||
2. 이름·이메일은 받지 않고 번호만 받는다.
|
||||
3. 번호는 결과 대조용이며 별도 마케팅 발송/3자 공유 용도가 아니라고 한 번 안내한다.
|
||||
4. `enter_event(phone)` 를 호출한다.
|
||||
5. `phone_format` 이면 서버 `message` 를 그대로 보여주고 재입력을 요청한다.
|
||||
6. `already_entered_today` 이면 서버 `message` 를 그대로 보여주고 더 시도하지 않는다.
|
||||
7. 성공 응답이면 `message`, `code`, `next_action` 을 함께 전달한다.
|
||||
|
||||
## 응답 정리 원칙
|
||||
|
||||
- `enter_event` 의 `message` 는 **글자 그대로** 전달한다.
|
||||
- 발급 코드는 `` `Jackpot-A3K9` `` 같이 모노스페이스로 강조한다.
|
||||
- Jackpot/Claw 사용 방법은 `next_action` 과 함께 짧게 설명한다.
|
||||
- 단체주문 문의는 `group_order_url` 이 비어 있으면 `group_order_note` 를 대신 제공한다.
|
||||
- 역삼점 외 다른 지점 문의에는 이 스킬 범위가 아니라는 점을 먼저 밝힌다.
|
||||
|
||||
## Verified remote MCP contract snapshot
|
||||
|
||||
아래 값은 `2026-04-16 KST` live smoke check(`initialize`, `tools/list`, `get_menu`, `get_shop_info`, `enter_event(phone='010-12')`) 기준으로 정리한 contract fixture다.
|
||||
|
||||
### initialize 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": {
|
||||
"name": "hola-poke-yeoksam",
|
||||
"version": "3.2.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tools/list 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_menu",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_shop_info",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "enter_event",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"phone"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### get_menu 구조 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2026-04-13",
|
||||
"currency": "KRW",
|
||||
"price_unit": "천원",
|
||||
"signature_poke": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "갈릭 쉬림프 포케",
|
||||
"price": 11.5,
|
||||
"tags": [
|
||||
"BEST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "아보카도 포케",
|
||||
"price": 10.5,
|
||||
"tags": [
|
||||
"VEGAN"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"name": "1인 포케+스프 세트",
|
||||
"items": "포케 + 스프",
|
||||
"price": 13.5,
|
||||
"price_note": "13.5~"
|
||||
},
|
||||
{
|
||||
"name": "1인 혼밥 든든세트",
|
||||
"items": "포케 + 스프 + 음료",
|
||||
"price": 15.5,
|
||||
"price_note": "15.5~"
|
||||
}
|
||||
],
|
||||
"addons": [
|
||||
{
|
||||
"name": "아보카도",
|
||||
"price": 3.5
|
||||
},
|
||||
{
|
||||
"name": "메밀면",
|
||||
"price": 1.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### get_shop_info 구조 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "올라포케 역삼점",
|
||||
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
|
||||
"hours": {
|
||||
"weekday": "10:30 - 20:30",
|
||||
"break_time": "15:00 - 17:00",
|
||||
"weekend": "영업시간 네이버 스마트플레이스 확인"
|
||||
},
|
||||
"delivery_radius_km": 3,
|
||||
"group_order_url": "",
|
||||
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
|
||||
"delivery_apps": [
|
||||
"배달의민족",
|
||||
"쿠팡이츠",
|
||||
"요기요"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### enter_event 성공 응답 필수 필드
|
||||
|
||||
실제 이벤트 참여를 발생시키지 않기 위해 성공 경로는 저장된 스냅샷 fixture 계약으로만 고정한다. 라이브 스모크는 invalid-phone 재시도 흐름만 검증한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"required_fields": [
|
||||
"message",
|
||||
"code",
|
||||
"next_action"
|
||||
],
|
||||
"accepts": [
|
||||
"01012345678",
|
||||
"010-1234-5678"
|
||||
],
|
||||
"stores_name_or_email": false
|
||||
}
|
||||
```
|
||||
|
||||
### enter_event(phone='010-12') 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "phone_format",
|
||||
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
|
||||
}
|
||||
```
|
||||
|
||||
## 제한사항
|
||||
|
||||
- 역삼점 전용이다.
|
||||
- 주문/결제/배달앱 자동화는 하지 않는다.
|
||||
- 단체주문 자동 예약을 대신 실행하지 않는다.
|
||||
- 이벤트 스킴은 시기별로 바뀔 수 있으므로 현재 혜택 조건의 진실 소스는 서버 `message` 다.
|
||||
- 동일 번호는 하루 1번만 응모 가능하므로 반복 요청을 강행하지 않는다.
|
||||
|
||||
## 참고 링크
|
||||
|
||||
- 원본 repo: `https://github.com/mnspkm/hola-poke-yeoksam-skill`
|
||||
- remote MCP endpoint: `https://hola-poke-yeoksam-skill.onrender.com/mcp`
|
||||
|
|
@ -2,104 +2,141 @@
|
|||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- `.hwp` 문서를 JSON, Markdown, HTML로 변환
|
||||
- 문서 안 이미지를 추출
|
||||
- 폴더 단위 배치 처리
|
||||
- Windows + 한글 프로그램 설치 환경에서는 직접 문서 조작까지 확장
|
||||
- `.hwp`, `.hwpx`, `.hwpml` 문서를 Markdown으로 변환
|
||||
- 문서를 JSON으로 구조화해 `blocks`, `metadata`까지 AI에 넘기기
|
||||
- 폴더 단위 배치 변환
|
||||
- `watch`로 폴더를 감시하며 새 문서를 계속 변환
|
||||
- 두 버전 문서 비교
|
||||
- HWPX 양식 필드 추출
|
||||
- Markdown을 다시 HWPX로 역변환
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 기본 경로: Node.js 18+
|
||||
- 기본 패키지: `npm install -g @ohah/hwpjs`
|
||||
- 실행 전: `export NODE_PATH="$(npm root -g)"`
|
||||
- 직접 제어가 필요할 때만: Windows + 한글(HWP) 프로그램 설치 + Python 3.7+
|
||||
- Node.js 18+
|
||||
- CLI를 한 번만 쓸 때: `npx --yes --package kordoc --package pdfjs-dist kordoc --help`
|
||||
- 반복 실행용 전역 설치: `npm install -g kordoc pdfjs-dist`
|
||||
- Node API 예시를 따라갈 로컬 작업 디렉터리: `npm init -y && npm install kordoc pdfjs-dist`
|
||||
- 현재 배포된 `kordoc` CLI는 시작 시 `pdfjs-dist`를 바로 불러오므로 PDF를 안 다뤄도 함께 설치해야 한다
|
||||
- `import { markdownToHwpx } from "kordoc"` 같은 ESM 예시는 전역 `NODE_PATH`가 아니라 로컬 설치 기준으로 실행해야 한다
|
||||
|
||||
## 어떤 경로를 선택하나
|
||||
|
||||
### 기본값: `@ohah/hwpjs`
|
||||
이 스킬의 기본 경로는 **항상 `kordoc`** 이다.
|
||||
|
||||
다음 상황에서는 `@ohah/hwpjs`를 사용한다.
|
||||
- 문서 읽기/변환 → `kordoc`
|
||||
- 구조화 JSON 추출 → `kordoc --format json`
|
||||
- 연속 입력 폴더 처리 → `kordoc watch`
|
||||
- 양식 필드 추출 → `parse()` + `extractFormFields()`
|
||||
- 역변환 → `markdownToHwpx()`
|
||||
- 문서 비교 → `compare()`
|
||||
|
||||
- macOS / Linux / CI
|
||||
- 읽기, 변환, 이미지 추출, 배치 처리
|
||||
- Windows여도 한글 프로그램 설치/연동을 확신할 수 없음
|
||||
|
||||
### 예외 경로: `hwp-mcp`
|
||||
|
||||
다음 조건을 모두 만족할 때만 `hwp-mcp`를 사용한다.
|
||||
|
||||
- Windows
|
||||
- 한글(HWP) 프로그램이 실제 설치되어 있음
|
||||
- 문서 생성, 텍스트 삽입, 표 채우기처럼 실행 중인 한글 직접 제어가 필요함
|
||||
|
||||
즉, **변환은 `@ohah/hwpjs`, 직접 조작은 `hwp-mcp`** 가 기본 규칙이다.
|
||||
이 스킬은 단일한 `kordoc` 경로를 표준 흐름으로 유지한다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. `node -p "process.platform"` 으로 운영체제를 확인한다.
|
||||
2. `win32` 가 아니면 `@ohah/hwpjs`를 사용한다.
|
||||
3. `win32` 여도 직접 제어 요건이 분명하지 않으면 `@ohah/hwpjs`를 사용한다.
|
||||
4. 직접 조작이 필요하고 한글 설치가 확인되면 `hwp-mcp`를 선택한다.
|
||||
5. 결과 파일 생성 여부와 출력 내용을 확인한다.
|
||||
1. `kordoc`이 없으면 설치한다.
|
||||
2. `.hwp`/`.hwpx`/`.hwpml`을 Markdown 또는 JSON으로 변환한다.
|
||||
3. 표·이미지·메타데이터가 필요하면 JSON의 `blocks` / `metadata`를 확인한다.
|
||||
4. 반복 입력 폴더는 `watch`, 양식 문서는 `extractFormFields`, 편집 roundtrip은 `markdownToHwpx` 경로로 이어간다.
|
||||
5. 결과 파일 생성 여부와 구조를 확인한다.
|
||||
|
||||
## 예시
|
||||
|
||||
### Markdown 변환
|
||||
|
||||
```bash
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 보고서.hwp -o 보고서.md
|
||||
```
|
||||
|
||||
### JSON 변환
|
||||
|
||||
```bash
|
||||
hwpjs to-json document.hwp -o output.json --pretty
|
||||
```
|
||||
|
||||
### Markdown 변환 + 이미지 포함
|
||||
|
||||
```bash
|
||||
hwpjs to-markdown document.hwp -o output.md --include-images
|
||||
```
|
||||
|
||||
`--include-images` 는 이미지 파일 경로를 따로 만드는 대신 Markdown 안에 base64 `data:` URI로 포함한다.
|
||||
|
||||
### HTML 변환
|
||||
|
||||
```bash
|
||||
hwpjs to-html document.hwp -o output.html
|
||||
```
|
||||
|
||||
### 이미지 추출
|
||||
|
||||
```bash
|
||||
hwpjs extract-images document.hwp -o ./images
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 검토서.hwpx --format json > 검토서.json
|
||||
```
|
||||
|
||||
### 배치 처리
|
||||
|
||||
```bash
|
||||
hwpjs batch ./documents -o ./output --format json --recursive
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc ./문서함/* -d ./변환결과
|
||||
```
|
||||
|
||||
### 페이지 범위 지정
|
||||
|
||||
```bash
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 보고서.hwp --pages 1-3
|
||||
```
|
||||
|
||||
### 디렉터리 감시 변환
|
||||
|
||||
```bash
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc watch ./문서함
|
||||
```
|
||||
|
||||
### 양식 필드 추출
|
||||
|
||||
아래 Node API 예시는 `package.json`이 있는 로컬 작업 디렉터리에서:
|
||||
|
||||
```bash
|
||||
npm init -y
|
||||
npm install kordoc pdfjs-dist
|
||||
```
|
||||
|
||||
이미 `package.json`이 있으면 `npm install kordoc pdfjs-dist`만 추가로 실행하면 된다.
|
||||
|
||||
```bash
|
||||
node --input-type=module - <<'EOF'
|
||||
import { parse, extractFormFields } from "kordoc";
|
||||
|
||||
const result = await parse("신청서.hwpx");
|
||||
if (!result.success) {
|
||||
console.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(extractFormFields(result.blocks), null, 2));
|
||||
EOF
|
||||
```
|
||||
|
||||
### Markdown → HWPX 역변환
|
||||
|
||||
```bash
|
||||
node --input-type=module - <<'EOF'
|
||||
import { markdownToHwpx } from "kordoc";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const hwpx = await markdownToHwpx("# 제목\n\n본문\n\n| 항목 | 값 |\n| --- | --- |\n| 성명 | 홍길동 |");
|
||||
writeFileSync("출력.hwpx", Buffer.from(hwpx));
|
||||
EOF
|
||||
```
|
||||
|
||||
### 문서 비교
|
||||
|
||||
```bash
|
||||
node --input-type=module - <<'EOF'
|
||||
import { compare } from "kordoc";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const before = readFileSync("이전버전.hwp");
|
||||
const after = readFileSync("최신버전.hwpx");
|
||||
const diff = await compare(before, after);
|
||||
console.log(diff.stats);
|
||||
EOF
|
||||
```
|
||||
|
||||
## 결과 확인 포인트
|
||||
|
||||
- JSON 출력: 파일 생성 여부와 최상위 구조를 확인한다.
|
||||
- Markdown 출력: `--include-images` 를 썼다면 이미지 파일 경로가 따로 생기지 않아도 정상이며, Markdown 안 `data:` URI / base64 인라인 포함 여부를 확인한다.
|
||||
- HTML 출력: 파일 생성 뒤 브라우저에서 열리는지 확인한다.
|
||||
- 이미지 추출: 출력 디렉터리에 실제 이미지 파일이 생겼는지 확인한다.
|
||||
- Markdown 출력: 제목/본문/표가 기대한 순서로 정리됐는지 확인한다.
|
||||
- JSON 출력: `success`, `blocks`, `metadata`가 있는지 확인한다.
|
||||
- 이미지/표 구조: `blocks` 안 `image`, `table` 타입이 필요한 만큼 잡혔는지 확인한다.
|
||||
- 배치 처리: 입력 개수와 출력 개수가 크게 어긋나지 않는지 확인한다.
|
||||
|
||||
이미지를 별도 파일로 떨궈야 한다면 `--include-images` 대신 `--images-dir` 경로를 쓴다.
|
||||
|
||||
## 직접 제어가 필요한 경우
|
||||
|
||||
`hwp-mcp`는 Windows + 한글 프로그램 설치 환경에서만 고려한다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jkf87/hwp-mcp.git
|
||||
cd hwp-mcp
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
그 뒤 MCP 서버로 연결해 새 문서 생성, 텍스트 삽입, 표 작성, 저장 같은 작업을 수행한다.
|
||||
- 양식 필드 추출: `extractFormFields(result.blocks)` 결과가 비어 있지 않은지 확인한다.
|
||||
- 역변환: 생성된 `.hwpx` 가 열리고 기본 서식/테이블이 유지되는지 확인한다.
|
||||
- 문서 비교: `diff.stats` 의 added / removed / modified 값이 입력 변화와 맞는지 확인한다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- `hwp-mcp`를 Linux/macOS에서 우회 실행하려 하지 않는다.
|
||||
- 직접 제어 필요성이 약하면 `@ohah/hwpjs`로 바로 끝내는 편이 더 안정적이다.
|
||||
- 배치 작업 후에는 입력 개수와 출력 개수를 같이 확인한다.
|
||||
- 손상된 문서나 일부 특수 양식은 경고가 섞일 수 있다.
|
||||
- 이미지 기반 PDF는 OCR provider가 없으면 품질이 제한될 수 있다.
|
||||
- 양식 필드 추출은 템플릿 라벨 품질에 따라 일부 필드가 인식되지 않을 수 있다.
|
||||
- 공문서 자동화 목적이면 Markdown만 보는 것보다 JSON `blocks`를 같이 확인하는 편이 안전하다.
|
||||
- 현재 배포본 기준으로 문서화된 CLI 명령은 기본 변환과 `watch` 이며, 양식 처리와 비교는 Node API 예시를 기준으로 잡는 편이 안전하다.
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/mfds/food-safety/search'
|
|||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
한국 주식 기본정보 endpoint:
|
||||
|
|
@ -199,7 +199,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@
|
|||
- KakaoTalk for Mac 설치
|
||||
- Homebrew
|
||||
- `brew install silver-flight-group/tap/kakaocli`
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
|
||||
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
|
||||
|
|
@ -36,15 +38,20 @@ mas install 869223134
|
|||
|
||||
1. KakaoTalk for Mac 과 `kakaocli` 가 설치되어 있는지 확인한다.
|
||||
2. `kakaocli status`, `kakaocli auth` 로 권한과 DB 접근이 되는지 먼저 확인한다.
|
||||
3. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
|
||||
4. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
|
||||
5. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
|
||||
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
|
||||
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
|
||||
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
|
||||
6. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
kakaocli auth
|
||||
python3 scripts/kakaotalk_mac.py auth --refresh
|
||||
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
|
||||
python3 scripts/kakaotalk_mac.py search "회의" --json
|
||||
kakaocli chats --limit 10 --json
|
||||
kakaocli messages --chat "지수" --since 1d --json
|
||||
kakaocli search "회의" --json
|
||||
|
|
@ -52,9 +59,26 @@ kakaocli send --me _ "테스트 메시지"
|
|||
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
|
||||
```
|
||||
|
||||
## helper 가 해결하는 문제
|
||||
|
||||
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
|
||||
|
||||
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
|
||||
- `kakaocli status` 는 정상이어도 `auth` 는 `user_id 자동 감지 실패` 로 끝날 수 있다.
|
||||
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
|
||||
|
||||
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
|
||||
|
||||
- plist 에서 후보 `user_id` 와 active hash 를 읽는다.
|
||||
- hash recovery 가 필요하면 더 긴 검색으로 실제 `user_id` 를 찾는다.
|
||||
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
|
||||
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
|
||||
- **Accessibility** 가 없으면 전송과 harvest 계열 자동화가 실패한다.
|
||||
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
|
||||
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
|
||||
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
|
|
|
|||
59
docs/features/kbl-results.md
Normal file
59
docs/features/kbl-results.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# KBL 경기 결과 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 날짜별 KBL 경기 일정 및 결과 조회
|
||||
- 특정 팀(`서울 SK`, `부산 KCC`, 팀 코드 등) 경기만 필터링
|
||||
- 현재 순위 확인
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- Node.js 18+
|
||||
- `npm install -g kbl-results`
|
||||
|
||||
## 입력값
|
||||
|
||||
- 날짜: `YYYY-MM-DD`
|
||||
- 선택 사항: 팀명, 풀네임, 팀 코드
|
||||
|
||||
## 공식 표면
|
||||
|
||||
이 기능은 브라우저 크롤링 전에 공식 JSON 표면을 직접 사용한다.
|
||||
|
||||
- KBL 일정/결과 API: `https://api.kbl.or.kr/match/list`
|
||||
- KBL 팀 순위 API: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 `kbl-results` 를 전역 설치한다.
|
||||
2. `match/list` 에 `fromDate` / `toDate` / `tcodeList` / `seasonGrade=1` 을 넣어 날짜별 경기 데이터를 가져온다.
|
||||
3. 요청 팀이 있으면 `서울 SK`, `SK`, `55`, `부산 KCC`, `KCC` 같은 alias 를 같은 팀으로 인식해 걸러낸다.
|
||||
4. `league/rank/team` 으로 현재 순위를 가져와 경기 결과와 함께 보여준다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "kbl-results", "src", "index.js"),
|
||||
).href;
|
||||
const { getKBLSummary } = await import(entry);
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "서울 SK",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- `match/list` 는 `YYYYMMDD` 파라미터를 받는다. 라이브러리가 `YYYY-MM-DD` 입력을 공식 포맷으로 바꾼다.
|
||||
- 기본 조회는 KBL 1군 기준이라 `seasonGrade=1` 을 사용한다.
|
||||
- 현재 순위는 `league/rank/team` 기준 현재 표를 사용한다.
|
||||
- 공식 JSON이 살아 있으므로 브라우저 scraping 은 기본 경로가 아니다.
|
||||
156
docs/features/korean-scholarship-search.md
Normal file
156
docs/features/korean-scholarship-search.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# 장학금 검색 및 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 한국장학재단, 전국 대학교, 재단, 기업, 공공기관의 최신 장학 공고 검색
|
||||
- 장학금별 금액, 지원 자격, 학자금 지원구간, 신청 기간, 링크 정리
|
||||
- 사용자 조건(학교, 학부/대학원, 전공, 학과, 금액, 기관 유형) 기반 필터링
|
||||
- KST(`Asia/Seoul`) 현재 날짜 기준으로 `지금 지원 가능`, `곧 열림`, `마감됨` 구분
|
||||
- 정규화된 JSON 목록에 대해 지원 가능 여부 빠른 판정
|
||||
- readable markdown report 출력
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
이 기능은 **공식 공고 우선** 이다.
|
||||
|
||||
- `kosaf.go.kr`, `*.ac.kr`, `*.go.kr`, `*.or.kr`, 공식 재단/기업 도메인을 우선 본다.
|
||||
- 블로그/커뮤니티/모음글은 lead source 로만 쓰고, 공식 공고로 검증되지 않으면 결과에서 제외한다.
|
||||
- 신청 기간은 반드시 절대 날짜로 적는다.
|
||||
- 최종 결과에는 공식 공고 링크와 신청 링크를 함께 남긴다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- 최신 웹 검색 가능 에이전트
|
||||
- 선택: `python3` 3.8+ (`scripts/scholarship_filter.py` helper 사용 시)
|
||||
|
||||
## 추천 검색 순서
|
||||
|
||||
1. 한국장학재단에서 전국 단위 장학/지원구간 관련 공고를 먼저 본다.
|
||||
2. 사용자 학교가 있으면 해당 학교 공식 장학 공지를 본다.
|
||||
3. 재단/기업/공공기관 공식 페이지를 추가로 찾는다.
|
||||
4. 각 공고에서 금액, 자격, 지원구간, 기간, 신청 링크를 정규화한다.
|
||||
5. 필요하면 helper로 필터링하고 지원 가능 여부를 본다.
|
||||
|
||||
특정 학교를 지정하면 학교 본부 장학공지, 학생지원처, 단과대, 학과/전공 홈페이지 공지를 순서대로 확인한다. 학교를 지정하지 않으면 전국 대학 `*.ac.kr` 공고를 넓게 탐색한다.
|
||||
|
||||
검색할 때는 `장학금` 만 보지 말고 `장학생 모집`, `외부 장학 추천`, `등록금 감면`, `생활비 지원`, `학업장려비`, `추천장학`, `근로장학` 같은 제목 단서도 같이 본다.
|
||||
|
||||
## 공식 표면 예시
|
||||
|
||||
- 한국장학재단 푸른등대 기부장학금: `https://www.kosaf.go.kr/ko/scholar.do?pg=scholarship05_11_01`
|
||||
- 한국장학재단 학자금 지원구간 산정절차: `https://www.kosaf.go.kr/ko/tuition.do?pg=tuition04_09_01&type=tuition`
|
||||
- 한국장학재단 학자금 지원구간 경곗값: `https://www.kosaf.go.kr/ko/tuition.do?naviParam=JH%2C01%2C01%2C03&pg=tuition04_09_07`
|
||||
- 삼성꿈장학재단: `https://www.sdream.or.kr/w/web60gV`
|
||||
- 대학 장학 공지: 각 대학 공식 `*.ac.kr` 학생지원처/장학공지
|
||||
|
||||
## helper 사용 예시
|
||||
|
||||
정규화된 장학금 JSON 파일(`scholarships.json`)이 있다고 가정:
|
||||
|
||||
### 재단 장학금만 + 학부생 + 5구간 이하 + 200만원 이상
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py filter \
|
||||
--input scholarships.json \
|
||||
--org-type foundation \
|
||||
--student-level undergraduate \
|
||||
--income-band 5 \
|
||||
--min-amount 2000000
|
||||
```
|
||||
|
||||
### 내 조건으로 지원 가능 여부 판정
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py eligibility \
|
||||
--input scholarships.json \
|
||||
--school-name "서울대학교" \
|
||||
--student-level undergraduate \
|
||||
--grade-year 2 \
|
||||
--gpa 3.5 \
|
||||
--income-band 5
|
||||
```
|
||||
|
||||
KST 기준 readable report:
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py report \
|
||||
--input scholarships.json \
|
||||
--today 2026-04-14 \
|
||||
--only-open-now
|
||||
```
|
||||
|
||||
학교별 exhaustive query plan 생성:
|
||||
|
||||
```bash
|
||||
python3 scripts/university_search_plan.py \
|
||||
--school-name "부산대학교" \
|
||||
--department "컴퓨터공학과" \
|
||||
--year 2026
|
||||
```
|
||||
|
||||
전국 대학 sweep query 생성:
|
||||
|
||||
```bash
|
||||
python3 scripts/university_search_plan.py --nationwide --year 2026
|
||||
```
|
||||
|
||||
## 바로 쓸 프롬프트 예시
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 지금 신청 가능하거나 곧 열리는 한국 장학금 공고를 찾아줘. 한국장학재단, 전국 대학교, 재단, 기업, 공공기관 공식 공고만 포함하고, KST 기준 현재 날짜로 열린 공고와 곧 열릴 공고를 나눠서 가독성 좋은 form으로 정리해줘.
|
||||
```
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 내 조건에 맞는 장학금만 찾아줘.
|
||||
- 학교: 서울대학교
|
||||
- 학생 구분: 학부생
|
||||
- 학년: 2학년
|
||||
- 전공: 컴퓨터공학
|
||||
- 학과: 컴퓨터공학부
|
||||
- 학자금 지원구간: 5구간
|
||||
- 최소 금액: 200만원
|
||||
- 기관 유형: 재단
|
||||
|
||||
지원 가능 여부도 같이 표시해줘.
|
||||
```
|
||||
|
||||
## 결과 form 권장
|
||||
|
||||
1. 요약: 총 후보 수 / 열린 공고 수 / 곧 열릴 공고 수
|
||||
2. `지금 지원 가능`
|
||||
3. `곧 열림`
|
||||
4. `마감됨`
|
||||
|
||||
각 항목은 아래 순서로 보여주면 읽기 좋다.
|
||||
|
||||
- 장학금명
|
||||
- 기관 / 기관 유형
|
||||
- 금액
|
||||
- 신청기간
|
||||
- KST 기준 현재 날짜 상태
|
||||
- 핵심 조건
|
||||
- 공식 공고 링크
|
||||
- 신청 링크
|
||||
|
||||
조건이 불명확한 항목은 숨기지 말고 `미확인` 으로 남긴다.
|
||||
|
||||
## 답변 템플릿 권장
|
||||
|
||||
- 장학금명
|
||||
- 운영기관 / 기관 유형
|
||||
- 지원 금액
|
||||
- 신청 기간
|
||||
- 핵심 자격 요건
|
||||
- 학자금 지원구간 조건
|
||||
- 공식 공고 링크
|
||||
- 신청 링크
|
||||
- 내 조건 기준 간단 판정
|
||||
|
||||
## 주의 사항
|
||||
|
||||
- `scripts/scholarship_filter.py` 에서 `--today` 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준으로 마감 상태를 계산한다.
|
||||
- "등록금 전액" 같이 금액이 정액이 아닌 공고는 원문 텍스트를 그대로 유지한다.
|
||||
- 성적 조건이 4.5 만점인지 100점 만점인지 공고마다 다르므로 원문 기준을 같이 적는다.
|
||||
- 장학금은 학기별/연도별로 반복되더라도 조건과 마감일이 달라질 수 있으니, 과거 공고를 최신 공고로 착각하지 않는다.
|
||||
- 학자금 지원구간 관련 설명은 한국장학재단 기준을 우선 참고한다.
|
||||
|
|
@ -31,7 +31,7 @@ upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
|
|||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
## 기본정보 예시
|
||||
|
|
@ -40,7 +40,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
## 일별 시세 예시
|
||||
|
|
@ -49,7 +49,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
## 응답 해석 팁
|
||||
|
|
@ -59,6 +59,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
|
||||
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
|
||||
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
|
||||
- 일부 시장 upstream 이 실패하면 검색 응답에 `upstream.degraded=true` 와 `failed_markets` 가 붙을 수 있다.
|
||||
|
||||
## 답변 템플릿 권장
|
||||
|
||||
|
|
@ -72,7 +73,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
|
||||
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
|
||||
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
|
||||
- upstream KRX 오류는 502
|
||||
- 검색 중 일부 시장 upstream 이 실패하면 200 이지만 `upstream.degraded=true` / `failed_markets` 가 함께 온다.
|
||||
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502
|
||||
- 기준일에 종목을 찾지 못하면 404 `not_found`
|
||||
|
||||
## 참고 링크
|
||||
|
|
|
|||
145
docs/features/public-restroom-nearby.md
Normal file
145
docs/features/public-restroom-nearby.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# 근처 공중화장실 찾기 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 현재 위치 기준 근처 공중화장실 / 개방화장실 검색
|
||||
- 동네/역명/랜드마크를 Kakao Map anchor 로 변환한 뒤 nearby 계산
|
||||
- 공식 `공중화장실정보` 표준데이터 기반 거리순 요약
|
||||
- 개방시간, 주소, 지도 링크까지 함께 정리
|
||||
|
||||
## 가장 먼저 할 일
|
||||
|
||||
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
|
||||
|
||||
권장 질문 예시:
|
||||
|
||||
```text
|
||||
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.
|
||||
```
|
||||
|
||||
## 입력값
|
||||
|
||||
- 동네/상권: `광화문`, `성수동`, `해운대`
|
||||
- 역명/랜드마크: `서울역`, `강남역`, `코엑스`
|
||||
- 좌표: `37.57103, 126.97679`
|
||||
|
||||
위치 문자열은 Kakao Map anchor 검색으로 **WGS84 좌표**를 잡고, anchor 주소에서 추론한 시도 코드가 있으면 해당 지역 CSV만 내려받습니다.
|
||||
|
||||
## 공식 표면
|
||||
|
||||
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
공식 CSV에는 화장실명, 주소, 위·경도, 남녀/장애인 화장실 수, 개방시간, 기저귀교환대, 비상벨 등이 담겨 있습니다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 유저에게 현재 위치를 먼저 묻습니다.
|
||||
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
|
||||
3. anchor 주소에서 서울/경기/부산 같은 시도 정보를 추론합니다.
|
||||
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬합니다.
|
||||
5. 가장 가까운 3~5개만 짧게 응답합니다.
|
||||
|
||||
## Node.js 예시
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
for (const item of result.items) {
|
||||
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
반경 제한이 필요하면 `maxDistanceMeters` 옵션으로 100m 같은 거리 캡을 줄 수 있습니다.
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3,
|
||||
maxDistanceMeters: 100
|
||||
});
|
||||
|
||||
console.log(`100m 이내 결과 수: ${result.meta.total}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Offline smoke example
|
||||
|
||||
fixture 기반 검증:
|
||||
|
||||
```bash
|
||||
node --test packages/public-restroom-nearby/test/index.test.js
|
||||
```
|
||||
|
||||
## 검증된 live smoke 예시
|
||||
|
||||
아래 값은 **2026-04-16** 에 `광화문`, `limit=3` 로 실제 호출해 확인한 결과 일부입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": {
|
||||
"name": "광화문",
|
||||
"address": "서울 종로구 사직로 161 (세종로)"
|
||||
},
|
||||
"meta": {
|
||||
"region": {
|
||||
"name": "서울특별시"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "세종로공영주차장",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "00~24"
|
||||
},
|
||||
{
|
||||
"name": "종로구청화장실",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "평일9시간(09:00~18:00)"
|
||||
},
|
||||
{
|
||||
"name": "세종문화회관 화장실",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "08~22"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 으로 확인했을 때는 `meta.total = 0` 이었습니다.
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- 좌표를 직접 받으면 anchor 검색을 생략해 더 빠르게 nearby 계산을 할 수 있습니다.
|
||||
- 화장실이 너무 많이 잡히는 지역이면 `maxDistanceMeters` 로 100m, 300m 같은 거리 캡을 먼저 걸어두세요.
|
||||
- CSV는 공개 표준데이터이므로 **실시간 잠금/점검 상태는 보장하지 않습니다**. 개방시간 위주로만 안내하세요.
|
||||
- 넓은 질의(예: `강남`)는 기준점이 흔들릴 수 있으니 필요하면 역명/동 이름으로 한 번 더 좁히세요.
|
||||
- 지도 링크가 필요하면 `item.mapUrl` 을 함께 전달하면 됩니다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 데이터는 공식 공개 CSV지만 실시간 availability API는 아닙니다.
|
||||
- CSV 인코딩은 CP949 계열일 수 있어 직접 구현할 때 디코딩 처리가 필요합니다.
|
||||
- Kakao Map anchor 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.
|
||||
|
|
@ -46,6 +46,7 @@ k-skill-setup 스킬을 사용해서 공통 설정을 진행해줘.
|
|||
npx --yes skills add <owner/repo> \
|
||||
--skill hwp \
|
||||
--skill kbo-results \
|
||||
--skill kbl-results \
|
||||
--skill kleague-results \
|
||||
--skill lck-analytics \
|
||||
--skill toss-securities \
|
||||
|
|
@ -54,6 +55,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill kakaotalk-mac \
|
||||
--skill korean-law-search \
|
||||
--skill real-estate-search \
|
||||
--skill korean-scholarship-search \
|
||||
--skill korean-stock-search \
|
||||
--skill household-waste-info \
|
||||
--skill mfds-drug-safety \
|
||||
|
|
@ -62,6 +64,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill korean-patent-search \
|
||||
--skill korea-weather \
|
||||
--skill cheap-gas-nearby \
|
||||
--skill public-restroom-nearby \
|
||||
--skill fine-dust-location \
|
||||
--skill han-river-water-level \
|
||||
--skill subway-lost-property \
|
||||
|
|
@ -69,6 +72,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill daiso-product-search \
|
||||
--skill market-kurly-search \
|
||||
--skill olive-young-search \
|
||||
--skill hola-poke-yeoksam \
|
||||
--skill blue-ribbon-nearby \
|
||||
--skill kakao-bar-nearby \
|
||||
--skill zipcode-search \
|
||||
|
|
@ -119,6 +123,8 @@ korean-law list
|
|||
|
||||
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
|
||||
|
||||
`korean-scholarship-search` 는 스킬 이름 `장학금 검색 및 조회` 로 동작한다. 별도 API key 없이 최신 웹 검색과 공식 공고 확인으로 장학금을 찾고, 한국장학재단·전국 대학교 본부·단과대·학과·재단·기업·공공기관 공고를 모아 금액/지원자격/지원구간/공식 링크를 정리한다. 설치된 helper `python3 scripts/scholarship_filter.py` 로 사용자 조건 필터링, KST(`Asia/Seoul`) 현재 날짜 기준 마감 상태 분류, readable report, 지원 가능 여부 확인을 할 수 있고, `python3 scripts/university_search_plan.py` 로 학교별 또는 전국 대학 검색 쿼리 팩을 만들 수 있다. 자세한 사용법은 [장학금 검색 및 조회 가이드](features/korean-scholarship-search.md)를 본다.
|
||||
|
||||
`korean-stock-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `KRX_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`. 자세한 사용법은 [한국 주식 정보 조회 가이드](features/korean-stock-search.md)를 본다.
|
||||
|
||||
`household-waste-info` 는 별도 설치 없이 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서만 원본 API(`apis.data.go.kr/1741000/household_waste_info/info`)로 주입한다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 자세한 사용법은 [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)를 본다.
|
||||
|
|
@ -127,19 +133,19 @@ korean-law list
|
|||
|
||||
`korean-stock-search` 는 로컬 MCP 설치 대신 **proxy first** 로 사용한다.
|
||||
|
||||
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260404'`
|
||||
- 가장 빠른 smoke test 는 `curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' --data-urlencode 'q=삼성전자' --data-urlencode 'bas_dd=20260408'`
|
||||
- 검색 결과에서 `market`, `code` 를 확인한 뒤 `base-info` 또는 `trade-info` 로 이어간다.
|
||||
- 사용자 쪽 `KRX_API_KEY` 는 필요 없다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 설정한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
|
||||
|
|
@ -255,10 +261,13 @@ npm run ci
|
|||
### Node 패키지
|
||||
|
||||
```bash
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm install kordoc pdfjs-dist` 후 실행한다.
|
||||
`kordoc` CLI를 일회성으로만 쓸 때는 `npx --yes --package kordoc --package pdfjs-dist kordoc ...` 형태를 사용한다.
|
||||
|
||||
### macOS 바이너리
|
||||
|
||||
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
|
||||
|
|
@ -288,6 +297,12 @@ export KIPRIS_PLUS_API_KEY=your-service-key
|
|||
python3 scripts/patent_search.py --query "배터리"
|
||||
```
|
||||
|
||||
장학금 검색 및 조회 helper는 설치된 `korean-scholarship-search` skill 안의 `scripts/scholarship_filter.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다. `--today` 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준으로 마감 상태를 계산한다.
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py report --input scholarships.json --today 2026-04-14 --only-open-now
|
||||
```
|
||||
|
||||
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
|
||||
|
||||
```bash
|
||||
|
|
@ -327,6 +342,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
|
|||
- `korean-stock-search`
|
||||
- `household-waste-info`
|
||||
- `cheap-gas-nearby`
|
||||
- `public-restroom-nearby`
|
||||
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)
|
||||
|
||||
관련 문서:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
- SRT
|
||||
- KTX
|
||||
- KBO 경기 결과
|
||||
- KBL 경기 결과 조회 스킬 출시
|
||||
- K리그 경기 결과 조회 스킬 출시
|
||||
- LCK 경기 분석 스킬 출시
|
||||
- 토스증권 조회 스킬 출시
|
||||
|
|
@ -20,10 +21,12 @@
|
|||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 장학금 검색 및 조회 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 근처 공중화장실 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
|
|
@ -31,6 +34,7 @@
|
|||
- 다이소 상품 조회 스킬 출시
|
||||
- 마켓컬리 상품 조회 스킬 출시
|
||||
- 올리브영 검색 스킬 출시
|
||||
- 올라포케 역삼 포케 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 번개장터 검색 스킬 출시
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ bash scripts/check-setup.sh
|
|||
- [하이패스 영수증 발급 가이드](features/hipass-receipt.md)
|
||||
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
|
||||
- [근처 공중화장실 찾기 가이드](features/public-restroom-nearby.md)
|
||||
- [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)
|
||||
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
|
||||
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
- `korail2` / `carpedm20/korail2`: https://github.com/carpedm20/korail2
|
||||
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
|
||||
- `kbo-game`: https://github.com/vkehfdl1/kbo-game
|
||||
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
|
||||
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team
|
||||
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
|
||||
- 하이패스 메인: https://www.hipass.co.kr/main.do
|
||||
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
|
||||
|
|
@ -23,10 +25,14 @@
|
|||
- Riot LoL Esports live window feed: https://feed.lolesports.com/livestats/v1/window/<gameId>
|
||||
- Riot LoL Esports live details feed: https://feed.lolesports.com/livestats/v1/details/<gameId>
|
||||
- Oracle's Elixir data glossary: https://oracleselixir.com/tools/downloads
|
||||
- `@ohah/hwpjs`: https://github.com/ohah/hwpjs
|
||||
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
|
||||
- `kordoc`: https://github.com/chrisryugj/kordoc
|
||||
- `pdfjs-dist`: https://www.npmjs.com/package/pdfjs-dist
|
||||
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
|
||||
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
|
||||
- 한국장학재단 학자금 지원구간 산정절차: https://www.kosaf.go.kr/ko/tuition.do?pg=tuition04_09_01&type=tuition
|
||||
- 한국장학재단 학자금 지원구간 경곗값 확인: https://www.kosaf.go.kr/ko/tuition.do?naviParam=JH%2C01%2C01%2C03&pg=tuition04_09_07
|
||||
- 한국장학재단 푸른등대 기부장학금: https://www.kosaf.go.kr/ko/scholar.do?pg=scholarship05_11_01
|
||||
- 삼성꿈장학재단: https://www.sdream.or.kr/w/web60gV
|
||||
- korea-stock-mcp: https://github.com/jjlabsio/korea-stock-mcp
|
||||
- 공공데이터포털 의약품개요정보(e약은요): https://www.data.go.kr/data/15075057/openapi.do
|
||||
- 식약처 e약은요 endpoint: https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList
|
||||
|
|
@ -81,6 +87,8 @@
|
|||
- olive-young products API: https://mcp.aka.page/api/oliveyoung/products
|
||||
- olive-young inventory API: https://mcp.aka.page/api/oliveyoung/inventory
|
||||
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
|
||||
- hola-poke-yeoksam reference repo: https://github.com/mnspkm/hola-poke-yeoksam-skill
|
||||
- hola-poke-yeoksam remote MCP endpoint: https://hola-poke-yeoksam-skill.onrender.com/mcp
|
||||
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
|
||||
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
|
||||
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
|
||||
|
|
@ -101,6 +109,10 @@
|
|||
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
|
||||
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
|
||||
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
|
||||
- 공공데이터포털 공중화장실 표준데이터: https://www.data.go.kr/data/15012892/standard.do
|
||||
- 공중화장실정보 파일 소개: https://file.localdata.go.kr/file/public_restroom_info/info
|
||||
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
|
||||
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
|
||||
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
|
||||
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
|
||||
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
|
||||
|
|
|
|||
257
hola-poke-yeoksam/SKILL.md
Normal file
257
hola-poke-yeoksam/SKILL.md
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
---
|
||||
name: hola-poke-yeoksam
|
||||
description: 올라포케 역삼점의 메뉴·매장 정보·이벤트 참여 흐름을 remote MCP 서버 기준으로 안내한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: food
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Hola Poke Yeoksam
|
||||
|
||||
## What this skill does
|
||||
|
||||
올라포케 역삼점 전용 remote MCP server(`https://hola-poke-yeoksam-skill.onrender.com/mcp`)를 기준으로 아래 작업을 처리한다.
|
||||
|
||||
- `get_menu()` 로 포케·사이드·세트·토핑 메뉴를 안내한다.
|
||||
- `get_shop_info()` 로 위치, 영업시간, 배달 반경, 단체주문 URL을 안내한다.
|
||||
- `enter_event(phone)` 로 즉석 래플형 이벤트 참여를 돕는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "올라포케 메뉴 뭐 있어?"
|
||||
- "역삼 포케 추천해줘"
|
||||
- "올라포케 역삼점 어디야?"
|
||||
- "올라포케 단체주문 링크 줘"
|
||||
- "올라포케 이벤트 참여해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 역삼점이 아닌 다른 올라포케 지점 문의
|
||||
- 주문/결제/배달앱 자동화는 하지 않는다.
|
||||
- 단체주문 자동 예약 실행
|
||||
- 사용자 동의 없는 번호 수집 또는 반복 응모 시도
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- remote MCP server 연결
|
||||
- 메뉴/매장 정보 조회용 MCP client
|
||||
- `enter_event` 호출 시 사용자 휴대폰 번호 (`01012345678` 또는 `010-1234-5678`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 메뉴/매장 정보 조회
|
||||
|
||||
- 메뉴가 궁금하면 `get_menu()` 를 호출한다.
|
||||
- 위치·영업시간·단체주문 문의는 `get_shop_info()` 를 호출한다.
|
||||
- 응답은 메뉴명, 가격, 주소, 영업시간, URL 같은 핵심 정보 위주로 짧게 정리한다.
|
||||
|
||||
### 2. 이벤트 참여
|
||||
|
||||
현재 스킴은 **즉석 래플** 이다. 식사 주문 시 쓸 수 있는 혜택 코드가 발급될 수 있고, 동일 번호는 하루 1번만 응모할 수 있다.
|
||||
|
||||
1. 사용자가 참여 의사를 밝히면 휴대폰 번호를 먼저 받는다.
|
||||
2. 이름·이메일은 받지 않고 번호만 받는다.
|
||||
3. 번호는 결과 대조용이며 별도 마케팅 발송/3자 공유 용도가 아니라고 한 번 고지한다.
|
||||
4. `enter_event(phone)` 를 호출한다.
|
||||
5. `phone_format` 이면 서버 `message` 를 그대로 보여주고 다시 받는다.
|
||||
6. `already_entered_today` 이면 서버 `message` 를 그대로 보여주고 더 이상 재시도하지 않는다.
|
||||
7. 정상 응답이면 `message`, `code`, `next_action` 을 함께 전달한다.
|
||||
|
||||
### 3. 응답 원칙
|
||||
|
||||
- `enter_event` 의 `message` 는 글자 그대로 전달한다.
|
||||
- 발급 코드는 `` `Jackpot-A3K9` `` 같은 모노스페이스로 강조한다.
|
||||
- Jackpot/Claw 사용 경로는 `next_action` 과 함께 안내한다.
|
||||
- 단체주문 문의는 `get_shop_info()` 의 `group_order_url` 이 비어 있으면 `group_order_note` 를 대신 안내한다.
|
||||
|
||||
## Remote MCP setup note
|
||||
|
||||
이 스킬은 자체 수집기를 vendoring 하지 않는다. 원본 참고 repo와 동일하게 아래 remote MCP endpoint 를 붙여 사용하는 전제다.
|
||||
|
||||
- endpoint: `https://hola-poke-yeoksam-skill.onrender.com/mcp`
|
||||
- reference repo: `https://github.com/mnspkm/hola-poke-yeoksam-skill`
|
||||
|
||||
## Verified remote MCP contract snapshot
|
||||
|
||||
아래 값은 `2026-04-16 KST` live smoke check(`initialize`, `tools/list`, `get_menu`, `get_shop_info`, `enter_event(phone='010-12')`) 기준으로 정리한 contract fixture다.
|
||||
|
||||
### initialize 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": {
|
||||
"name": "hola-poke-yeoksam",
|
||||
"version": "3.2.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### tools/list 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_menu",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_shop_info",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "enter_event",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"phone"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### get_menu 구조 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2026-04-13",
|
||||
"currency": "KRW",
|
||||
"price_unit": "천원",
|
||||
"signature_poke": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "갈릭 쉬림프 포케",
|
||||
"price": 11.5,
|
||||
"tags": [
|
||||
"BEST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "아보카도 포케",
|
||||
"price": 10.5,
|
||||
"tags": [
|
||||
"VEGAN"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"name": "1인 포케+스프 세트",
|
||||
"items": "포케 + 스프",
|
||||
"price": 13.5,
|
||||
"price_note": "13.5~"
|
||||
},
|
||||
{
|
||||
"name": "1인 혼밥 든든세트",
|
||||
"items": "포케 + 스프 + 음료",
|
||||
"price": 15.5,
|
||||
"price_note": "15.5~"
|
||||
}
|
||||
],
|
||||
"addons": [
|
||||
{
|
||||
"name": "아보카도",
|
||||
"price": 3.5
|
||||
},
|
||||
{
|
||||
"name": "메밀면",
|
||||
"price": 1.5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### get_shop_info 구조 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "올라포케 역삼점",
|
||||
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
|
||||
"hours": {
|
||||
"weekday": "10:30 - 20:30",
|
||||
"break_time": "15:00 - 17:00",
|
||||
"weekend": "영업시간 네이버 스마트플레이스 확인"
|
||||
},
|
||||
"delivery_radius_km": 3,
|
||||
"group_order_url": "",
|
||||
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
|
||||
"delivery_apps": [
|
||||
"배달의민족",
|
||||
"쿠팡이츠",
|
||||
"요기요"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### enter_event 성공 응답 필수 필드
|
||||
|
||||
실제 이벤트 참여를 발생시키지 않기 위해 성공 경로는 저장된 스냅샷 fixture 계약으로만 고정한다. 라이브 스모크는 invalid-phone 재시도 흐름만 검증한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"required_fields": [
|
||||
"message",
|
||||
"code",
|
||||
"next_action"
|
||||
],
|
||||
"accepts": [
|
||||
"01012345678",
|
||||
"010-1234-5678"
|
||||
],
|
||||
"stores_name_or_email": false
|
||||
}
|
||||
```
|
||||
|
||||
### enter_event(phone='010-12') 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "phone_format",
|
||||
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
|
||||
}
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- 메뉴/매장 정보 요청에 핵심 정보를 전달했다.
|
||||
- 이벤트 참여 요청에 번호 확인 → `enter_event` → 결과 전달 흐름을 지켰다.
|
||||
- 반복 응모 제한과 서버 `message` 원문 전달 원칙을 지켰다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 역삼점 전용 스킬이다.
|
||||
- 이벤트 스킴은 시기별로 달라질 수 있으므로 보상 조건의 진실 소스는 서버 응답의 `message` 필드다.
|
||||
- Jackpot 당첨은 번호 주인 확인이 필요할 수 있다.
|
||||
- 동일 번호는 KST 기준 하루 1번만 응모한다.
|
||||
204
hwp/SKILL.md
204
hwp/SKILL.md
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: hwp
|
||||
description: Convert HWP files to JSON, Markdown, or HTML, extract images, and choose between @ohah/hwpjs and hwp-mcp based on OS and local Hangul availability.
|
||||
description: Use kordoc for agent-native HWP/HWPX document parsing, JSON extraction, diffing, form-field extraction, and Markdown→HWPX reverse conversion.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: documents
|
||||
|
|
@ -12,171 +12,199 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
`.hwp` 문서를 읽어 JSON / Markdown / HTML로 변환하고, 이미지 추출이나 배치 처리를 수행한다.
|
||||
환경이 **Windows + 한글(HWP) 프로그램 설치 + 직접 제어가 필요한 작업**이면 `hwp-mcp`를 선택하고, 그 외에는 기본값으로 `@ohah/hwpjs`를 사용한다.
|
||||
`kordoc`으로 `.hwp` / `.hwpx` / `.hwpml` 문서를 AI가 읽기 좋은 Markdown 또는 JSON으로 바꾸고,
|
||||
필요하면 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환까지 수행한다.
|
||||
|
||||
이 스킬의 기본 엔진은 **항상 `kordoc`** 이다. 문서 변환, 비교, 필드 추출, 역변환까지 같은 도구로 일관되게 처리한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "이 HWP 파일을 Markdown으로 바꿔줘"
|
||||
- "한글 문서에서 이미지만 뽑아줘"
|
||||
- "폴더 안 HWP를 한 번에 JSON으로 변환해줘"
|
||||
- "윈도우에서 한글 프로그램을 직접 조작해서 표 채워줘"
|
||||
- "공문서를 JSON 구조로 뽑아서 AI가 읽게 해줘"
|
||||
- "두 버전 문서 차이점을 보고 싶어"
|
||||
- "신청서 HWPX 안에 어떤 양식 필드가 있는지 뽑아줘"
|
||||
- "AI가 만든 Markdown을 다시 HWPX로 저장해줘"
|
||||
- "폴더 안 문서를 한 번에 변환해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 원본이 `.hwpx`, `.docx`, `.pdf` 인 경우
|
||||
- Windows가 아니거나 한글 프로그램이 없는데 직접 편집 자동화를 요구하는 경우
|
||||
- OCR이나 스캔 PDF 복구가 필요한 경우
|
||||
- OCR이 필수인데 OCR provider 연결이 전혀 없는 이미지 기반 PDF만 있는 경우
|
||||
- `.docx`, `.xlsx`, `.pdf` 만 다루더라도 문서 파싱 자체가 아니라 편집기 GUI 자동화가 필요한 경우
|
||||
- 원본 프로그램의 실시간 UI 제어가 반드시 필요한 경우
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 공통 변환 경로: Node.js 18+
|
||||
- 직접 제어 경로: Windows + 한글(HWP) 프로그램 설치 + Python 3.7+
|
||||
- Node.js 18+
|
||||
- 출력 경로 쓰기 권한
|
||||
- `kordoc`과 `pdfjs-dist`를 같은 전역/로컬 환경에 설치했거나, 둘 다 포함된 `npx --yes --package kordoc --package pdfjs-dist kordoc ...` 실행 환경
|
||||
- 현재 배포된 `kordoc` CLI는 시작 시 `pdfjs-dist`를 바로 로드하므로 PDF를 안 써도 함께 설치해야 한다
|
||||
|
||||
## Inputs
|
||||
|
||||
- 원본 `.hwp` 파일 경로 또는 폴더 경로
|
||||
- 원하는 출력 형식: `json`, `markdown`, `html`
|
||||
- 원본 `.hwp`, `.hwpx`, `.hwpml` 파일 경로 또는 폴더/글롭 경로
|
||||
- 원하는 결과 형태: `markdown`, `json`, `hwpx`
|
||||
- 출력 파일/디렉터리 경로
|
||||
- 이미지 포함/추출 여부
|
||||
- 배치 처리 여부
|
||||
- 직접 제어가 필요한지 여부
|
||||
- 페이지 범위 지정 여부
|
||||
- 비교 / 양식 필드 추출 / 역변환 여부
|
||||
|
||||
## Routing policy
|
||||
|
||||
### Default: `@ohah/hwpjs`
|
||||
### Default: `kordoc`
|
||||
|
||||
다음 조건 중 하나라도 맞으면 `@ohah/hwpjs`를 기본값으로 사용한다.
|
||||
다음 작업은 모두 기본적으로 `kordoc`으로 처리한다.
|
||||
|
||||
- macOS / Linux / CI 환경
|
||||
- Windows여도 한글 프로그램 설치 여부를 확신할 수 없음
|
||||
- 읽기 / 변환 / 이미지 추출 / 배치 처리 중심 작업
|
||||
- HWP/HWPX/HWPML → Markdown
|
||||
- HWP/HWPX/HWPML → JSON (`blocks`, `metadata`)
|
||||
- 배치 변환
|
||||
- 페이지 범위 파싱
|
||||
- 이미지/표/양식이 포함된 공문서 구조 추출
|
||||
- 디렉터리 감시 변환 (`watch`)
|
||||
- Markdown→HWPX 역변환
|
||||
- HWPX 양식 필드 추출
|
||||
|
||||
### Windows direct-control path: `hwp-mcp`
|
||||
### Optional library path
|
||||
|
||||
다음 조건을 모두 만족할 때만 `hwp-mcp`를 선택한다.
|
||||
CLI만으로 부족하면 Node API를 사용한다.
|
||||
|
||||
- 운영체제가 Windows
|
||||
- 한글(HWP) 프로그램이 실제로 설치되어 있음
|
||||
- 문서 생성, 텍스트 삽입, 표 채우기, 저장 같은 **실행 중인 한글 프로그램 직접 제어**가 필요함
|
||||
|
||||
직접 제어 조건이 불분명하면 추측하지 말고 `@ohah/hwpjs`로 처리 가능한 범위부터 진행한다.
|
||||
- `parse()` — Markdown + 구조화 블록
|
||||
- `compare()` — 신구 문서 비교
|
||||
- `extractFormFields()` — 파싱된 블록에서 양식 필드 추출
|
||||
- `markdownToHwpx()` — Markdown→HWPX 역변환
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Detect the environment first
|
||||
### 1. Prepare the CLI runtime
|
||||
|
||||
일회성 변환이면 둘 다 포함한 `npx` 형태를 바로 쓴다.
|
||||
|
||||
```bash
|
||||
node -p "process.platform"
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc --help
|
||||
```
|
||||
|
||||
- 결과가 `win32`가 아니면 `@ohah/hwpjs`
|
||||
- 결과가 `win32`여도 한글 프로그램 직접 제어가 확인되지 않으면 `@ohah/hwpjs`
|
||||
- `win32` 이고 한글 프로그램이 실제로 설치되어 있으며 직접 조작이 필요하면 `hwp-mcp`
|
||||
|
||||
### 1. Install the chosen backend when missing
|
||||
|
||||
#### `@ohah/hwpjs`
|
||||
반복 실행용 전역 설치가 필요하면:
|
||||
|
||||
```bash
|
||||
npm install -g @ohah/hwpjs
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
npm install -g kordoc pdfjs-dist
|
||||
```
|
||||
|
||||
#### `hwp-mcp`
|
||||
현재 배포된 `kordoc` CLI는 `pdfjs-dist`가 없으면 `kordoc --help` 단계부터 실패하므로
|
||||
깨끗한 환경에서는 두 패키지를 같이 설치한 뒤 실행한다.
|
||||
|
||||
### 2. Prepare a local project for Node API examples
|
||||
|
||||
`parse()`, `compare()`, `extractFormFields()`, `markdownToHwpx()` 같은 ESM 예시는
|
||||
전역 `NODE_PATH`가 아니라 **로컬 프로젝트 설치** 기준으로 실행한다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jkf87/hwp-mcp.git
|
||||
cd hwp-mcp
|
||||
pip install -r requirements.txt
|
||||
mkdir -p ./kordoc-local && cd ./kordoc-local
|
||||
npm init -y
|
||||
npm install kordoc pdfjs-dist
|
||||
```
|
||||
|
||||
`hwp-mcp`는 Windows와 한글 프로그램 설치가 전제다. 이 전제가 깨지면 억지로 진행하지 말고 `@ohah/hwpjs`로 되돌린다.
|
||||
이미 `package.json`이 있는 작업 디렉터리라면 `npm install kordoc pdfjs-dist`만 추가로 실행하면 된다.
|
||||
|
||||
### 2. Prefer `@ohah/hwpjs` for conversions and extraction
|
||||
|
||||
#### JSON 변환
|
||||
### 3. Convert a document to Markdown
|
||||
|
||||
```bash
|
||||
hwpjs to-json document.hwp -o output.json --pretty
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 보고서.hwp -o 보고서.md
|
||||
```
|
||||
|
||||
#### Markdown 변환
|
||||
여러 문서를 한 번에 처리하려면:
|
||||
|
||||
```bash
|
||||
hwpjs to-markdown document.hwp -o output.md --include-images
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc ./문서함/* -d ./변환결과
|
||||
```
|
||||
|
||||
`--include-images` 는 이미지를 별도 파일로 떨구지 않고 Markdown 안에 base64 `data:` URI로 인라인한다.
|
||||
이미지를 파일로 따로 저장해야 하면 다음처럼 `--images-dir` 를 사용한다.
|
||||
특정 페이지 범위만 읽고 싶으면:
|
||||
|
||||
```bash
|
||||
hwpjs to-markdown document.hwp -o output.md --images-dir ./images
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 보고서.hwp --pages 1-3
|
||||
```
|
||||
|
||||
#### HTML 변환
|
||||
### 4. Extract structured JSON for AI/automation
|
||||
|
||||
```bash
|
||||
hwpjs to-html document.hwp -o output.html
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc 검토서.hwpx --format json > 검토서.json
|
||||
```
|
||||
|
||||
#### 이미지 추출
|
||||
JSON 결과에서는 `success`, `markdown`, `blocks`, `metadata`를 우선 확인한다.
|
||||
표나 이미지가 중요하면 `blocks` 안의 `table`, `image` 타입을 확인한다.
|
||||
|
||||
### 5. Inspect HWPX form fields from parsed blocks
|
||||
|
||||
```bash
|
||||
hwpjs extract-images document.hwp -o ./images
|
||||
node --input-type=module - <<'EOF'
|
||||
import { parse, extractFormFields } from "kordoc";
|
||||
|
||||
const result = await parse("신청서.hwpx");
|
||||
if (!result.success) {
|
||||
console.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fields = extractFormFields(result.blocks);
|
||||
console.log(JSON.stringify(fields, null, 2));
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 배치 처리
|
||||
자동 변환이 계속 들어오는 폴더면 CLI의 `watch` 명령을 쓴다.
|
||||
|
||||
```bash
|
||||
hwpjs batch ./documents -o ./output --format json --recursive
|
||||
npx --yes --package kordoc --package pdfjs-dist kordoc watch ./문서함
|
||||
```
|
||||
|
||||
배치 출력 형식은 로컬 설치 버전의 `hwpjs batch --help` 를 확인해 맞춘다.
|
||||
### 6. Reverse-convert Markdown back to HWPX
|
||||
|
||||
### 3. Use `hwp-mcp` only for live HWP control on Windows
|
||||
```bash
|
||||
node --input-type=module - <<'EOF'
|
||||
import { markdownToHwpx } from "kordoc";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
Claude/Codex MCP 설정에 `hwp_mcp_stdio_server.py` 를 등록한 뒤 다음 종류의 작업에 사용한다.
|
||||
const hwpx = await markdownToHwpx("# 제목\n\n본문\n\n| 항목 | 값 |\n| --- | --- |\n| 성명 | 홍길동 |");
|
||||
writeFileSync("출력.hwpx", Buffer.from(hwpx));
|
||||
EOF
|
||||
```
|
||||
|
||||
- 새 문서 생성
|
||||
- 텍스트 삽입
|
||||
- 표 생성 / 채우기
|
||||
- 저장
|
||||
- 여러 편집 명령을 묶은 배치 작업
|
||||
### 7. Compare two document versions when diff matters
|
||||
|
||||
직접 제어 예시는 다음 범주에 한정한다.
|
||||
```bash
|
||||
node --input-type=module - <<'EOF'
|
||||
import { compare } from "kordoc";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
- 보고서 템플릿 채우기
|
||||
- 표 데이터 입력
|
||||
- 정해진 서식 문서 생성
|
||||
const before = readFileSync("이전버전.hwp");
|
||||
const after = readFileSync("최신버전.hwpx");
|
||||
const diff = await compare(before, after);
|
||||
console.log(diff.stats);
|
||||
EOF
|
||||
```
|
||||
|
||||
### 4. Verify outputs after every run
|
||||
## Verify outputs after every run
|
||||
|
||||
- JSON: 파일 생성 여부와 최상위 구조 확인
|
||||
- Markdown: 본문 생성 여부와 `data:` URI / base64 이미지 인라인 포함 여부 확인 (`--include-images` 사용 시)
|
||||
- Markdown: 이미지 파일 분리가 목적이면 `--images-dir` 출력 디렉터리에 실제 파일이 생겼는지 확인
|
||||
- HTML: 파일 생성 후 브라우저 렌더링 가능 여부 확인
|
||||
- 이미지 추출: 출력 디렉터리에 파일이 실제로 생겼는지 확인
|
||||
- 배치 처리: 입력 개수와 출력 개수가 대략 맞는지 확인
|
||||
- Markdown: 파일이 생성되었고 제목/본문/표 구조가 깨지지 않았는지 확인
|
||||
- JSON: `success: true` 와 `blocks` / `metadata` 존재 여부 확인
|
||||
- 배치 처리: 입력 수와 출력 수가 크게 어긋나지 않는지 확인
|
||||
- 양식 필드 추출: `extractFormFields(result.blocks)` 결과가 비어 있지 않은지 확인
|
||||
- 역변환: 생성된 `.hwpx` 파일이 열리고 기본 서식/테이블 구조가 유지되는지 확인
|
||||
- 비교: `diff.stats` 에 added / removed / modified 값이 합리적인지 확인
|
||||
|
||||
## Done when
|
||||
|
||||
- 요청한 형식의 결과물이 생성되어 있다
|
||||
- 이미지 요청이 있으면 추출 파일 또는 Markdown 안 `data:` URI 인라인 결과가 확인되어 있다
|
||||
- 요청한 Markdown / JSON / HWPX 결과물이 생성되어 있다
|
||||
- 공문서 표·이미지·메타데이터가 필요한 수준으로 확인되어 있다
|
||||
- 양식 필드 추출이나 역변환 요청이 있었다면 결과/출력 구조까지 검증되어 있다
|
||||
- 배치 요청이면 처리 범위와 실패 건수가 정리되어 있다
|
||||
- Windows 직접 제어 작업이면 어떤 조작을 수행했는지 남아 있다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- 손상된 `.hwp` 파일
|
||||
- 전역 `hwpjs` 미설치
|
||||
- Windows가 아니어서 `hwp-mcp`를 사용할 수 없음
|
||||
- 한글 프로그램 미설치 또는 자동화 연결 실패
|
||||
- 손상된 HWP/HWPX/HWPML 파일
|
||||
- 암호화/배포 제한 문서에서 일부 파싱 한계 발생
|
||||
- 이미지 기반 PDF인데 OCR provider가 없음
|
||||
- 출력 디렉터리 권한 부족
|
||||
- 양식 라벨이 템플릿 안에서 예상과 다르게 배치되어 일부 필드가 인식되지 않음
|
||||
|
||||
## Notes
|
||||
|
||||
- 기본 선택지는 언제나 `@ohah/hwpjs`다.
|
||||
- `hwp-mcp`는 Windows + 한글 설치 환경에서만 직접 제어용으로 사용한다.
|
||||
- 직접 제어가 실패해도 읽기/변환 작업으로 충분하면 `@ohah/hwpjs` 경로로 축소해 완료한다.
|
||||
- `kordoc`은 HWP/HWPX뿐 아니라 HWPML, PDF, XLSX, DOCX도 함께 다룬다.
|
||||
- 기본 목적은 **AI가 읽을 수 있는 Markdown/JSON 변환** 이다.
|
||||
- 현재 배포본 기준으로 문서화된 CLI 명령은 기본 변환과 `watch` 이며, 양식 처리는 `extractFormFields()` 같은 Node API로 연결한다.
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ metadata:
|
|||
- Homebrew
|
||||
- Mac App Store 로그인(`mas` 사용 시)
|
||||
- `kakaocli` 설치
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
|
||||
## Inputs
|
||||
|
|
@ -99,6 +101,28 @@ kakaocli chats --limit 10 --json
|
|||
|
||||
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
|
||||
|
||||
### 3.5. Use the helper when `kakaocli auth` fails on `user_id` auto-detection
|
||||
|
||||
실제 macOS 환경에서는 `KakaoTalk.db` 라는 literal 파일이 없어도, container 안의 **78자 hex 파일**이 실제 SQLCipher DB 인 경우가 있다. 이때 `kakaocli status` 는 정상인데 `kakaocli auth` 만 실패하는 대표 원인은:
|
||||
|
||||
- `AlertKakaoIDsList` 후보로는 복호화가 안 됨
|
||||
- plist 의 `DESIGNATEDFRIENDSREVISION:<sha512(user_id)>` 만 남아 있음
|
||||
- upstream `kakaocli` 의 기본 `user_id` brute-force 시간이 짧아서 auto-detection 이 실패함
|
||||
|
||||
이 저장소는 그 구간만 보완하는 **read-only helper** 를 함께 제공한다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py auth --refresh
|
||||
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
|
||||
python3 scripts/kakaotalk_mac.py search "회의" --json
|
||||
```
|
||||
|
||||
- helper 는 plist 의 `AlertKakaoIDsList` 와 `DESIGNATEDFRIENDSREVISION` hash 를 읽는다.
|
||||
- 후보 `user_id` 가 모두 실패하면 SHA-512 preimage search 로 실제 `user_id` 를 더 오래 찾는다.
|
||||
- 성공한 `user_id`, DB 경로, derived key 는 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
|
||||
- 이후 read-only helper 명령(`chats`, `messages`, `search`, `schema`)은 cached `--db` / `--key` 를 붙여 `kakaocli` 를 다시 호출한다.
|
||||
|
||||
### 4. Read or search messages
|
||||
|
||||
```bash
|
||||
|
|
@ -106,6 +130,13 @@ kakaocli messages --chat "지수" --since 1h --json
|
|||
kakaocli search "점심" --json
|
||||
```
|
||||
|
||||
helper 경유 예시:
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
|
||||
python3 scripts/kakaotalk_mac.py search "점심" --json
|
||||
```
|
||||
|
||||
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
|
||||
|
||||
### 5. Use safe testing before real sends
|
||||
|
|
@ -158,6 +189,7 @@ kakaocli login --status
|
|||
- App Store 로그인 누락으로 `mas install` 실패
|
||||
- Full Disk Access 미부여
|
||||
- Accessibility 미부여
|
||||
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
|
||||
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
|
||||
|
||||
## Notes
|
||||
|
|
@ -165,3 +197,6 @@ kakaocli login --status
|
|||
- 이 스킬은 macOS 전용이다.
|
||||
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
|
||||
- 첫 검증은 `kakaocli status` 와 `kakaocli auth` 부터 시작하는 편이 안전하다.
|
||||
- `kakaocli auth` 가 `User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
|
||||
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
|
|
|
|||
658
kakaotalk-mac/scripts/kakaotalk_mac.py
Normal file
658
kakaotalk-mac/scripts/kakaotalk_mac.py
Normal file
|
|
@ -0,0 +1,658 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Sequence
|
||||
|
||||
|
||||
EMPTY_ACCOUNT_HASH = (
|
||||
"31bca02094eb78126a517b206a88c73cfa9ec6f704c7030d18212cace820f025"
|
||||
"f00bf0ea68dbf3f3a5436ca63b53bf7bf80ad8d5de7d8359d0b7fed9dbc3ab99"
|
||||
)
|
||||
HEX_DATABASE_PATTERN = re.compile(r"^[0-9a-f]{78}(?:\.db)?$")
|
||||
DIRECT_USER_ID_KEYS = ("userId", "user_id", "KAKAO_USER_ID", "userID")
|
||||
DEFAULT_MAX_USER_ID = 1_000_000_000
|
||||
DEFAULT_CHUNK_SIZE = 500_000
|
||||
DEFAULT_CACHE_PATH = Path.home() / ".cache" / "k-skill" / "kakaotalk-mac-auth.json"
|
||||
READ_ONLY_COMMANDS = ("chats", "messages", "search", "schema")
|
||||
|
||||
|
||||
class AuthResolutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionState:
|
||||
uuid: str
|
||||
candidate_user_ids: list[int]
|
||||
active_account_hash: str | None
|
||||
database_files: list[Path]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedAuth:
|
||||
user_id: int
|
||||
uuid: str
|
||||
database_path: Path
|
||||
database_name: str
|
||||
key: str
|
||||
source: str
|
||||
|
||||
|
||||
def parse_plist_xml(xml_text: str) -> Any:
|
||||
tokens = tokenize_plist_xml(xml_text)
|
||||
if not tokens:
|
||||
raise AuthResolutionError("plist XML was empty")
|
||||
index = 0
|
||||
if tokens[index] != ("start", "plist"):
|
||||
raise AuthResolutionError("plist XML did not start with <plist>")
|
||||
value, index = _parse_plist_tokens(tokens, index + 1)
|
||||
if tokens[index] != ("end", "plist"):
|
||||
raise AuthResolutionError("plist XML did not end with </plist>")
|
||||
return value
|
||||
|
||||
|
||||
def tokenize_plist_xml(xml_text: str) -> list[tuple[str, str]]:
|
||||
normalized = re.sub(r"<\?xml[^>]*\?>", "", xml_text)
|
||||
normalized = re.sub(r"<!DOCTYPE[^>]*>", "", normalized)
|
||||
normalized = re.sub(r"<([A-Za-z0-9]+)\s*/>", r"<\1></\1>", normalized)
|
||||
normalized = (
|
||||
normalized.replace("\r", "")
|
||||
)
|
||||
tokens: list[tuple[str, str]] = []
|
||||
position = 0
|
||||
for match in re.finditer(r"<(/?)([A-Za-z0-9]+)(?: [^>]*)?>", normalized):
|
||||
text = normalized[position : match.start()]
|
||||
stripped = _unescape_xml(text).strip()
|
||||
if stripped:
|
||||
tokens.append(("text", stripped))
|
||||
token_type = "end" if match.group(1) else "start"
|
||||
tokens.append((token_type, match.group(2)))
|
||||
position = match.end()
|
||||
trailing = _unescape_xml(normalized[position:]).strip()
|
||||
if trailing:
|
||||
tokens.append(("text", trailing))
|
||||
return [token for token in tokens if token[0] != "text" or token[1]]
|
||||
|
||||
|
||||
def _parse_plist_tokens(tokens: list[tuple[str, str]], index: int) -> tuple[Any, int]:
|
||||
token_type, tag = tokens[index]
|
||||
if token_type != "start":
|
||||
raise AuthResolutionError(f"Unexpected token {tokens[index]!r}")
|
||||
|
||||
if tag == "dict":
|
||||
result: dict[str, Any] = {}
|
||||
index += 1
|
||||
while tokens[index] != ("end", "dict"):
|
||||
if tokens[index] != ("start", "key"):
|
||||
raise AuthResolutionError(f"Expected dict key, got {tokens[index]!r}")
|
||||
key, index = _parse_scalar(tokens, index, "key", lambda value: value)
|
||||
value, index = _parse_plist_tokens(tokens, index)
|
||||
result[key] = value
|
||||
return result, index + 1
|
||||
|
||||
if tag == "array":
|
||||
items: list[Any] = []
|
||||
index += 1
|
||||
while tokens[index] != ("end", "array"):
|
||||
value, index = _parse_plist_tokens(tokens, index)
|
||||
items.append(value)
|
||||
return items, index + 1
|
||||
|
||||
if tag == "integer":
|
||||
return _parse_scalar(tokens, index, "integer", int)
|
||||
if tag == "real":
|
||||
return _parse_scalar(tokens, index, "real", float)
|
||||
if tag == "string":
|
||||
return _parse_scalar(tokens, index, "string", lambda value: value)
|
||||
if tag == "date":
|
||||
return _parse_scalar(tokens, index, "date", lambda value: value)
|
||||
if tag == "data":
|
||||
return _parse_scalar(tokens, index, "data", lambda value: value)
|
||||
if tag == "true":
|
||||
return True, index + 2
|
||||
if tag == "false":
|
||||
return False, index + 2
|
||||
raise AuthResolutionError(f"Unsupported plist tag: {tag}")
|
||||
|
||||
|
||||
def _parse_scalar(
|
||||
tokens: list[tuple[str, str]],
|
||||
index: int,
|
||||
tag: str,
|
||||
caster: Callable[[str], Any],
|
||||
) -> tuple[Any, int]:
|
||||
if tokens[index] != ("start", tag):
|
||||
raise AuthResolutionError(f"Expected <{tag}>, got {tokens[index]!r}")
|
||||
text = ""
|
||||
index += 1
|
||||
if tokens[index][0] == "text":
|
||||
text = tokens[index][1]
|
||||
index += 1
|
||||
if tokens[index] != ("end", tag):
|
||||
raise AuthResolutionError(f"Expected </{tag}>, got {tokens[index]!r}")
|
||||
return caster(text), index + 1
|
||||
|
||||
|
||||
def _unescape_xml(text: str) -> str:
|
||||
return (
|
||||
text.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
def collect_candidate_user_ids(plist_data: dict[str, Any]) -> list[int]:
|
||||
candidates: list[int] = []
|
||||
for key in DIRECT_USER_ID_KEYS:
|
||||
value = plist_data.get(key)
|
||||
if isinstance(value, int) and value > 0:
|
||||
candidates.append(value)
|
||||
elif isinstance(value, str) and value.isdigit():
|
||||
candidates.append(int(value))
|
||||
|
||||
alert_ids = plist_data.get("AlertKakaoIDsList", [])
|
||||
if isinstance(alert_ids, list):
|
||||
for item in alert_ids:
|
||||
if isinstance(item, int) and item > 0:
|
||||
candidates.append(item)
|
||||
elif isinstance(item, str) and item.isdigit():
|
||||
numeric = int(item)
|
||||
if numeric > 0:
|
||||
candidates.append(numeric)
|
||||
|
||||
return unique_ints(candidates)
|
||||
|
||||
|
||||
def find_active_account_hash(plist_data: dict[str, Any]) -> str | None:
|
||||
prefix = "DESIGNATEDFRIENDSREVISION:"
|
||||
for key, value in plist_data.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
hash_hex = key[len(prefix) :]
|
||||
if hash_hex == EMPTY_ACCOUNT_HASH:
|
||||
continue
|
||||
if not re.fullmatch(r"[0-9a-f]{128}", hash_hex):
|
||||
continue
|
||||
numeric_value = 0
|
||||
if isinstance(value, (int, float)):
|
||||
numeric_value = int(value)
|
||||
elif isinstance(value, str) and value.isdigit():
|
||||
numeric_value = int(value)
|
||||
if numeric_value != 0:
|
||||
return hash_hex
|
||||
return None
|
||||
|
||||
|
||||
def discover_database_files(container_path: Path) -> list[Path]:
|
||||
if not container_path.exists():
|
||||
return []
|
||||
return sorted(
|
||||
[path for path in container_path.iterdir() if path.is_file() and HEX_DATABASE_PATTERN.fullmatch(path.name)],
|
||||
key=lambda item: item.name,
|
||||
)
|
||||
|
||||
|
||||
def unique_ints(values: Iterable[int]) -> list[int]:
|
||||
seen: set[int] = set()
|
||||
ordered: list[int] = []
|
||||
for value in values:
|
||||
if value not in seen:
|
||||
seen.add(value)
|
||||
ordered.append(value)
|
||||
return ordered
|
||||
|
||||
|
||||
def recover_user_id_from_sha512(
|
||||
hex_hash: str,
|
||||
*,
|
||||
max_user_id: int = DEFAULT_MAX_USER_ID,
|
||||
workers: int | None = None,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
) -> int | None:
|
||||
if not re.fullmatch(r"[0-9a-f]{128}", hex_hash):
|
||||
raise ValueError("expected 128-char lowercase sha512 hex digest")
|
||||
if max_user_id < 0:
|
||||
raise ValueError("max_user_id must be non-negative")
|
||||
|
||||
normalized_workers = max(1, workers or (os.cpu_count() or 1))
|
||||
if normalized_workers == 1:
|
||||
return _scan_user_id_range((0, max_user_id + 1, hex_hash))
|
||||
|
||||
start_method = "fork" if "fork" in mp.get_all_start_methods() else mp.get_start_method()
|
||||
ctx = mp.get_context(start_method)
|
||||
job_iter = (
|
||||
(start, min(start + chunk_size, max_user_id + 1), hex_hash)
|
||||
for start in range(0, max_user_id + 1, chunk_size)
|
||||
)
|
||||
with ctx.Pool(processes=normalized_workers) as pool:
|
||||
for result in pool.imap_unordered(_scan_user_id_range, job_iter, chunksize=1):
|
||||
if result is not None:
|
||||
pool.terminate()
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def _scan_user_id_range(job: tuple[int, int, str]) -> int | None:
|
||||
start, end, hex_hash = job
|
||||
target = hex_hash.encode("ascii")
|
||||
for user_id in range(start, end):
|
||||
if hashlib.sha512(str(user_id).encode("utf-8")).hexdigest().encode("ascii") == target:
|
||||
return user_id
|
||||
return None
|
||||
|
||||
|
||||
def database_name(user_id: int, uuid: str) -> str:
|
||||
hawawa = ".".join([".", "F", str(user_id), "A", "F", uuid[::-1], ".", "|"])
|
||||
salt = hashed_device_uuid(uuid)[::-1].encode("utf-8")
|
||||
derived = hashlib.pbkdf2_hmac("sha256", hawawa.encode("utf-8"), salt, 100_000, 128)
|
||||
hex_value = derived.hex()
|
||||
return hex_value[28 : 28 + 78]
|
||||
|
||||
|
||||
def secure_key(user_id: int, uuid: str) -> str:
|
||||
hashed = hashed_device_uuid(uuid)
|
||||
parts = ["A", hashed, "|", "F", uuid[:5], "H", str(user_id), "|", uuid[7:]]
|
||||
hawawa = "F".join(parts)[::-1].encode("utf-8")
|
||||
salt = uuid[int(len(uuid) * 0.3) :].encode("utf-8")
|
||||
return hashlib.pbkdf2_hmac("sha256", hawawa, salt, 100_000, 128).hex()
|
||||
|
||||
|
||||
def hashed_device_uuid(uuid: str) -> str:
|
||||
uuid_bytes = uuid.encode("utf-8")
|
||||
combined = hashlib.sha1(uuid_bytes).digest() + hashlib.sha256(uuid_bytes).digest()
|
||||
return base64.b64encode(combined).decode("ascii")
|
||||
|
||||
|
||||
def resolve_auth_state(
|
||||
state: DetectionState,
|
||||
*,
|
||||
verify_access: Callable[[ResolvedAuth], bool],
|
||||
cache_path: Path | None = None,
|
||||
user_id_override: int | None = None,
|
||||
max_user_id: int = DEFAULT_MAX_USER_ID,
|
||||
workers: int | None = None,
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||
) -> ResolvedAuth:
|
||||
if not state.database_files:
|
||||
raise AuthResolutionError("No KakaoTalk database files were discovered in the container path.")
|
||||
|
||||
candidates = list(state.candidate_user_ids)
|
||||
if user_id_override is not None:
|
||||
candidates = [user_id_override, *candidates]
|
||||
candidates = unique_ints(candidates)
|
||||
|
||||
for user_id in candidates:
|
||||
resolved = _try_resolved_auth(user_id, "candidate", state, verify_access)
|
||||
if resolved is not None:
|
||||
persist_auth_cache(resolved, cache_path)
|
||||
return resolved
|
||||
|
||||
if state.active_account_hash:
|
||||
recovered = recover_user_id_from_sha512(
|
||||
state.active_account_hash,
|
||||
max_user_id=max_user_id,
|
||||
workers=workers,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
if recovered is not None and recovered not in candidates:
|
||||
resolved = _try_resolved_auth(recovered, "hash-recovery", state, verify_access)
|
||||
if resolved is not None:
|
||||
persist_auth_cache(resolved, cache_path)
|
||||
return resolved
|
||||
|
||||
raise AuthResolutionError(
|
||||
"Failed to resolve a working KakaoTalk auth key. "
|
||||
"Try a larger --max-user-id or pass --user-id explicitly."
|
||||
)
|
||||
|
||||
|
||||
def _try_resolved_auth(
|
||||
user_id: int,
|
||||
source: str,
|
||||
state: DetectionState,
|
||||
verify_access: Callable[[ResolvedAuth], bool],
|
||||
) -> ResolvedAuth | None:
|
||||
derived_name = database_name(user_id, state.uuid)
|
||||
key = secure_key(user_id, state.uuid)
|
||||
for database_path in prioritized_database_paths(state.database_files, derived_name):
|
||||
resolved = ResolvedAuth(
|
||||
user_id=user_id,
|
||||
uuid=state.uuid,
|
||||
database_path=database_path,
|
||||
database_name=derived_name,
|
||||
key=key,
|
||||
source=source,
|
||||
)
|
||||
if verify_access(resolved):
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def prioritized_database_paths(database_files: Sequence[Path], derived_name: str) -> list[Path]:
|
||||
preferred_names = {derived_name, f"{derived_name}.db"}
|
||||
preferred = [path for path in database_files if path.name in preferred_names]
|
||||
fallback = [path for path in database_files if path.name not in preferred_names]
|
||||
return [*preferred, *fallback]
|
||||
|
||||
|
||||
def load_cached_auth(cache_path: Path) -> ResolvedAuth | None:
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
database_path = Path(payload["database_path"]).expanduser()
|
||||
user_id = int(payload["user_id"])
|
||||
uuid = str(payload["uuid"])
|
||||
database_name = str(payload["database_name"])
|
||||
key = str(payload["key"])
|
||||
source = str(payload.get("source", "cache"))
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
if user_id <= 0 or not uuid or not database_name or not key or not database_path.exists():
|
||||
return None
|
||||
|
||||
return ResolvedAuth(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
database_path=database_path,
|
||||
database_name=database_name,
|
||||
key=key,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def persist_auth_cache(resolved: ResolvedAuth, cache_path: Path | None) -> None:
|
||||
if cache_path is None:
|
||||
return
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"user_id": resolved.user_id,
|
||||
"uuid": resolved.uuid,
|
||||
"database_path": str(resolved.database_path),
|
||||
"database_name": resolved.database_name,
|
||||
"key": resolved.key,
|
||||
"source": resolved.source,
|
||||
}
|
||||
cache_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(cache_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def platform_uuid() -> str:
|
||||
result = run_command(["/usr/sbin/ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], check=True)
|
||||
match = re.search(r'"IOPlatformUUID" = "([0-9A-F-]+)"', result.stdout)
|
||||
if not match:
|
||||
raise AuthResolutionError("Could not read IOPlatformUUID from ioreg output.")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def convert_plist_to_xml(plist_path: Path) -> str:
|
||||
result = run_command(["/usr/bin/plutil", "-convert", "xml1", "-o", "-", str(plist_path)], check=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def read_plist_snapshot(plist_path: Path) -> dict[str, Any]:
|
||||
return parse_plist_xml(convert_plist_to_xml(plist_path))
|
||||
|
||||
|
||||
def collect_detection_state(uuid_override: str | None = None) -> DetectionState:
|
||||
uuid = uuid_override or platform_uuid()
|
||||
snapshots = []
|
||||
for plist_path in preference_paths():
|
||||
if plist_path.exists():
|
||||
snapshots.append(read_plist_snapshot(plist_path))
|
||||
|
||||
candidate_user_ids: list[int] = []
|
||||
active_account_hash: str | None = None
|
||||
for snapshot in snapshots:
|
||||
candidate_user_ids.extend(collect_candidate_user_ids(snapshot))
|
||||
if active_account_hash is None:
|
||||
active_account_hash = find_active_account_hash(snapshot)
|
||||
|
||||
return DetectionState(
|
||||
uuid=uuid,
|
||||
candidate_user_ids=unique_ints(candidate_user_ids),
|
||||
active_account_hash=active_account_hash,
|
||||
database_files=discover_database_files(container_path()),
|
||||
)
|
||||
|
||||
|
||||
def preference_paths() -> list[Path]:
|
||||
pref_dir = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Containers"
|
||||
/ "com.kakao.KakaoTalkMac"
|
||||
/ "Data"
|
||||
/ "Library"
|
||||
/ "Preferences"
|
||||
)
|
||||
paths = sorted(pref_dir.glob("com.kakao.KakaoTalkMac*.plist"))
|
||||
global_pref = Path.home() / "Library" / "Preferences" / "com.kakao.KakaoTalkMac.plist"
|
||||
if global_pref.exists():
|
||||
paths.append(global_pref)
|
||||
deduped: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for path in paths:
|
||||
if path not in seen:
|
||||
seen.add(path)
|
||||
deduped.append(path)
|
||||
return deduped
|
||||
|
||||
|
||||
def container_path() -> Path:
|
||||
return (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Containers"
|
||||
/ "com.kakao.KakaoTalkMac"
|
||||
/ "Data"
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "com.kakao.KakaoTalkMac"
|
||||
)
|
||||
|
||||
|
||||
def verify_database_access(resolved: ResolvedAuth) -> bool:
|
||||
result = run_command(
|
||||
[
|
||||
"kakaocli",
|
||||
"query",
|
||||
"SELECT count(*) FROM sqlite_master",
|
||||
"--db",
|
||||
str(resolved.database_path),
|
||||
"--key",
|
||||
resolved.key,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def run_command(args: Sequence[str], *, check: bool) -> subprocess.CompletedProcess[str]:
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False)
|
||||
if check and result.returncode != 0:
|
||||
raise AuthResolutionError(result.stderr.strip() or result.stdout.strip() or f"command failed: {' '.join(args)}")
|
||||
return result
|
||||
|
||||
|
||||
def resolve_auth(
|
||||
*,
|
||||
refresh: bool,
|
||||
cache_path: Path,
|
||||
user_id_override: int | None,
|
||||
uuid_override: str | None,
|
||||
max_user_id: int,
|
||||
workers: int | None,
|
||||
chunk_size: int,
|
||||
) -> ResolvedAuth:
|
||||
use_cache = not refresh and user_id_override is None and uuid_override is None
|
||||
if use_cache:
|
||||
cached = load_cached_auth(cache_path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
state = collect_detection_state(uuid_override)
|
||||
return resolve_auth_state(
|
||||
state,
|
||||
verify_access=verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=user_id_override,
|
||||
max_user_id=max_user_id,
|
||||
workers=workers,
|
||||
chunk_size=chunk_size,
|
||||
)
|
||||
|
||||
|
||||
def render_auth(resolved: ResolvedAuth, *, output_format: str, cache_path: Path) -> str:
|
||||
payload = {
|
||||
"user_id": resolved.user_id,
|
||||
"uuid": resolved.uuid,
|
||||
"database_path": str(resolved.database_path),
|
||||
"database_name": resolved.database_name,
|
||||
"key": resolved.key,
|
||||
"source": resolved.source,
|
||||
"cache_path": str(cache_path),
|
||||
}
|
||||
if output_format == "json":
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if output_format == "shell":
|
||||
return "\n".join(
|
||||
[
|
||||
f"export KSKILL_KAKAOTALK_USER_ID='{resolved.user_id}'",
|
||||
f"export KSKILL_KAKAOTALK_UUID='{resolved.uuid}'",
|
||||
f"export KSKILL_KAKAOTALK_DB='{resolved.database_path}'",
|
||||
f"export KSKILL_KAKAOTALK_KEY='{resolved.key}'",
|
||||
f"export KSKILL_KAKAOTALK_AUTH_CACHE='{cache_path}'",
|
||||
]
|
||||
)
|
||||
return "\n".join(
|
||||
[
|
||||
"KakaoTalk auth resolved",
|
||||
f"- user_id: {resolved.user_id}",
|
||||
f"- uuid: {resolved.uuid}",
|
||||
f"- database: {resolved.database_path}",
|
||||
f"- source: {resolved.source}",
|
||||
f"- cache: {cache_path}",
|
||||
"- secrets: redacted in text output (use --format json or --format shell only when automation needs them)",
|
||||
"",
|
||||
"You can now run:",
|
||||
" python3 scripts/kakaotalk_mac.py chats --limit 10 --json",
|
||||
" python3 scripts/kakaotalk_mac.py messages --chat \"채팅방 이름\" --since 1d --json",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_passthrough_command(command: str, auth: ResolvedAuth, forwarded_args: Sequence[str]) -> list[str]:
|
||||
if command not in READ_ONLY_COMMANDS:
|
||||
raise AuthResolutionError(
|
||||
f"Unsupported command '{command}'. Allowed read-only commands: {', '.join(READ_ONLY_COMMANDS)}"
|
||||
)
|
||||
return [
|
||||
"kakaocli",
|
||||
command,
|
||||
*forwarded_args,
|
||||
"--db",
|
||||
str(auth.database_path),
|
||||
"--key",
|
||||
auth.key,
|
||||
]
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
|
||||
try:
|
||||
args, forwarded_args = parser.parse_known_args(argv)
|
||||
cache_path = Path(args.cache_path).expanduser()
|
||||
if args.command == "auth":
|
||||
if forwarded_args:
|
||||
raise AuthResolutionError(f"Unexpected auth arguments: {' '.join(forwarded_args)}")
|
||||
resolved = resolve_auth(
|
||||
refresh=args.refresh,
|
||||
cache_path=cache_path,
|
||||
user_id_override=args.user_id,
|
||||
uuid_override=args.uuid,
|
||||
max_user_id=args.max_user_id,
|
||||
workers=args.workers,
|
||||
chunk_size=args.chunk_size,
|
||||
)
|
||||
print(render_auth(resolved, output_format=args.format, cache_path=cache_path))
|
||||
return 0
|
||||
|
||||
resolved = resolve_auth(
|
||||
refresh=args.refresh_auth,
|
||||
cache_path=cache_path,
|
||||
user_id_override=args.user_id,
|
||||
uuid_override=args.uuid,
|
||||
max_user_id=args.max_user_id,
|
||||
workers=args.workers,
|
||||
chunk_size=args.chunk_size,
|
||||
)
|
||||
result = subprocess.run(build_passthrough_command(args.command, resolved, forwarded_args))
|
||||
return result.returncode
|
||||
except (AuthResolutionError, ValueError) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Thin k-skill adapter around kakaocli auth for user_id/hash recovery and cached read access.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
auth_parser = subparsers.add_parser("auth", help="Recover/cache the working KakaoTalk DB/key tuple.")
|
||||
add_auth_options(auth_parser)
|
||||
auth_parser.add_argument("--refresh", action="store_true", help="Ignore cached auth and resolve again.")
|
||||
auth_parser.add_argument("--format", choices=("text", "json", "shell"), default="text")
|
||||
|
||||
for command in READ_ONLY_COMMANDS:
|
||||
passthrough = subparsers.add_parser(command, help=f"Run kakaocli {command} with cached/recovered auth.")
|
||||
add_auth_options(passthrough)
|
||||
passthrough.add_argument("--refresh-auth", action="store_true", help="Refresh cached auth before running.")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def add_auth_options(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--cache-path", default=str(DEFAULT_CACHE_PATH))
|
||||
parser.add_argument("--user-id", type=int, help="Explicit Kakao user_id override.")
|
||||
parser.add_argument("--uuid", help="Explicit device UUID override.")
|
||||
parser.add_argument("--max-user-id", type=non_negative_int, default=DEFAULT_MAX_USER_ID)
|
||||
parser.add_argument("--workers", type=positive_int, default=None)
|
||||
parser.add_argument("--chunk-size", type=positive_int, default=DEFAULT_CHUNK_SIZE)
|
||||
|
||||
|
||||
def non_negative_int(value: str) -> int:
|
||||
integer = int(value)
|
||||
if integer < 0:
|
||||
raise argparse.ArgumentTypeError("must be non-negative")
|
||||
return integer
|
||||
|
||||
|
||||
def positive_int(value: str) -> int:
|
||||
integer = int(value)
|
||||
if integer <= 0:
|
||||
raise argparse.ArgumentTypeError("must be positive")
|
||||
return integer
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
99
kbl-results/SKILL.md
Normal file
99
kbl-results/SKILL.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
name: kbl-results
|
||||
description: KBL 한국프로농구 경기 결과와 현재 팀 순위를 날짜/팀 기준으로 조회한다. 공식 JSON 엔드포인트와 kbl-results npm 패키지를 사용한다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: sports
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# KBL Results
|
||||
|
||||
## What this skill does
|
||||
|
||||
공식 KBL JSON 표면으로 특정 날짜의 한국프로농구 경기 일정/결과를 조회하고, 필요하면 특정 팀(예: `서울 SK`, `부산 KCC`, 팀 코드 `55`)만 필터링한 뒤 현재 팀 순위까지 함께 정리한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "오늘 KBL 경기 결과 알려줘"
|
||||
- "2026-04-01 서울 SK 경기 결과 보여줘"
|
||||
- "KBL 현재 팀 순위 알려줘"
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- `npm install -g kbl-results`
|
||||
|
||||
## Inputs
|
||||
|
||||
- 날짜: `YYYY-MM-DD`
|
||||
- 선택 사항: 팀명, 풀네임, 팀 코드
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Install the package globally when missing
|
||||
|
||||
`npm root -g` 아래에 `kbl-results` 가 없으면 HTML scraping 으로 우회하지 말고 먼저 전역 Node 패키지 설치를 시도한다.
|
||||
|
||||
```bash
|
||||
npm install -g kbl-results
|
||||
```
|
||||
|
||||
### 1. Fetch the official KBL JSON
|
||||
|
||||
공식 KBL 웹앱은 `https://api.kbl.or.kr` JSON API를 사용한다. 따라서 브라우저 크롤링 전에 아래 표면을 우선 사용한다.
|
||||
|
||||
- 일정/결과: `https://api.kbl.or.kr/match/list`
|
||||
- 팀 순위: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
```bash
|
||||
GLOBAL_NPM_ROOT="$(npm root -g)" node --input-type=module - <<'JS'
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const entry = pathToFileURL(
|
||||
path.join(process.env.GLOBAL_NPM_ROOT, "kbl-results", "src", "index.js"),
|
||||
).href;
|
||||
const { getKBLSummary } = await import(entry);
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "부산 KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
JS
|
||||
```
|
||||
|
||||
### 2. Normalize for humans
|
||||
|
||||
원본 JSON을 그대로 던지지 말고 아래 기준으로 정리한다.
|
||||
|
||||
- 홈팀 vs 원정팀
|
||||
- 경기 시간 / 종료 여부 / LIVE 여부
|
||||
- 스코어
|
||||
- 현재 순위
|
||||
- 요청 팀이 있으면 해당 팀 경기만 필터링
|
||||
|
||||
### 3. Keep the answer compact
|
||||
|
||||
요청이 scoreboard 면 경기별 한 줄 요약부터 준다. 특정 팀 요청이면 그 팀 경기와 현재 순위만 먼저 보여준다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 날짜 기준 경기 요약이 있다
|
||||
- 팀 요청이면 해당 팀 경기만 남아 있다
|
||||
- 현재 순위가 같이 정리되어 있다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- KBL가 `api.kbl.or.kr` 응답 구조를 바꾸면 패키지 수정이 필요하다
|
||||
- 경기 전 날짜면 결과 대신 예정 상태가 반환될 수 있다
|
||||
- 크롤링 fallback은 공식 JSON이 막혔을 때만 검토한다
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 조회 전용이다
|
||||
- 사용자의 "오늘/어제" 요청은 항상 절대 날짜(`YYYY-MM-DD`)로 변환해서 실행한다
|
||||
- 자세한 사용 예시는 `docs/features/kbl-results.md` 와 `packages/kbl-results/README.md` 를 따른다
|
||||
329
korean-scholarship-search/SKILL.md
Normal file
329
korean-scholarship-search/SKILL.md
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
---
|
||||
name: korean-scholarship-search
|
||||
description: Search Korean scholarship announcements across official KOSAF, university, foundation, company, and public-sector sources, extract amount and eligibility, and filter results by school, income band, student level, and organization type. Users may invoke it with the phrase 장학금 검색 및 조회.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: education
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# 장학금 검색 및 조회
|
||||
|
||||
사용자에게는 `장학금 검색 및 조회` 라는 이름으로 안내하고, skill slug는 `korean-scholarship-search` 이다.
|
||||
|
||||
## What this skill does
|
||||
|
||||
한국장학재단, 대학, 재단, 기업, 지자체/공공기관의 **공식 장학 공고**를 최신 기준으로 검색하고 아래 항목을 정리한다.
|
||||
|
||||
이 스킬은 **공식 공고 우선** 이다.
|
||||
|
||||
- 장학금명
|
||||
- 운영기관명 / 기관 유형 (`school`, `foundation`, `government`, `company`, `local-government`, `other`)
|
||||
- 지원 금액 / 등록금·생활비 구분
|
||||
- 신청 기간
|
||||
- 지원 조건 / 지원 자격
|
||||
- 학자금 지원구간(소득구간) 조건
|
||||
- 공식 공고 링크 / 신청 링크
|
||||
|
||||
특정 학교가 주어지면 그 학교의 본부, 학생지원처, 단과대, 학과/전공, 대학원 공지를 전수 탐색하려고 시도한다. 학교가 주어지지 않으면 `*.ac.kr` 전체를 기준으로 전국 대학 장학 공고를 넓게 찾는다.
|
||||
|
||||
필요하면 동봉된 helper(`scripts/scholarship_filter.py`)로 사용자 조건에 맞게 후처리 필터링하고, 지원 가능 여부를 빠르게 판정하고, KST(`Asia/Seoul`) 현재 날짜 기준 readable report를 만든다. `--today` 를 생략하거나 잘못 넣으면 host local time 이 아니라 KST 오늘 날짜를 기준일로 사용한다.
|
||||
|
||||
## Works in both Claude Code and Codex
|
||||
|
||||
- 이 스킬은 특정 에이전트 전용이 아니다.
|
||||
- Claude Code에서도 사용 가능하고, Codex에서도 사용 가능하다.
|
||||
- 핵심은 에이전트가 최신 웹 검색을 할 수 있어야 한다는 점이다.
|
||||
- 장학금 마감일과 자격은 자주 바뀌므로 **항상 fresh search** 를 우선한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "한국 장학금 전부 찾아줘"
|
||||
- "서울대 학부생이 지원 가능한 재단 장학금 찾아줘"
|
||||
- "생활비 200만원 이상 주는 장학금만 골라줘"
|
||||
- "학교 장학금 말고 민간재단 장학금만 보고 싶어"
|
||||
- "학자금 지원구간 5구간 이하 대상 장학금만 정리해줘"
|
||||
- "컴퓨터공학과 대학원생 장학금 링크까지 정리해줘"
|
||||
- "내 조건으로 지원 가능한지 같이 판정해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 장학금 신청서 직접 제출/자동 접수
|
||||
- 비공개 커뮤니티/로그인 뒤에서만 보이는 모집공고 수집
|
||||
- 법률 자문이나 합격 보장 판단
|
||||
|
||||
## Source priority
|
||||
|
||||
항상 아래 우선순위를 따른다.
|
||||
|
||||
1. 한국장학재단 공식 페이지 (`kosaf.go.kr`)
|
||||
2. 대학 공식 도메인 (`*.ac.kr`)의 학생지원처/장학공지/학사공지
|
||||
3. 공공기관/지자체/재단 공식 페이지 (`*.go.kr`, `*.or.kr`, 공식 재단 도메인)
|
||||
4. 기업 공식 CSR/재단/채용·공지 페이지
|
||||
5. 비공식 모음글/블로그/커뮤니티는 **lead source** 로만 사용하고, 공식 공고로 교차검증되지 않으면 제외
|
||||
|
||||
소스별 검색 패턴은 `references/source-patterns.md` 를 보고, 검색 누락을 줄이려면 `references/search-clues.md` 의 키워드와 제목 단서를 같이 쓴다.
|
||||
|
||||
## Inputs
|
||||
|
||||
- 사용자 프로필
|
||||
- 학교명 / 학교 유형
|
||||
- 학부/대학원/고등학생 여부
|
||||
- 학년
|
||||
- 전공
|
||||
- GPA 또는 백분위
|
||||
- 학자금 지원구간
|
||||
- 선호 조건
|
||||
- 기관 유형 (`school`, `foundation`, `government`, `company`, `local-government`)
|
||||
- 최소/최대 금액
|
||||
- 등록금형 / 생활비형
|
||||
- 마감 상태 (`open`, `upcoming`)
|
||||
- 특정 지역 / 특정 학교 / 특정 재단
|
||||
|
||||
사용자가 필터링을 원하지만 핵심 입력이 비어 있으면, 한 번에 1~3개만 짧게 보강 질문한다.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 최신 웹 검색 가능 환경
|
||||
- 인터넷 연결
|
||||
- 선택: `python3` 3.8+ (`scripts/scholarship_filter.py` helper 사용 시)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 검색 범위를 먼저 정한다
|
||||
|
||||
- 사용자가 "전체"를 원하면 학교/재단/공공기관을 다 포함해 넓게 찾는다.
|
||||
- 사용자가 "재단만", "학교 공고만", "생활비만" 같은 제약을 주면 그 제약부터 적용한다.
|
||||
- 날짜 관련 표현은 반드시 절대 날짜로 정리한다.
|
||||
|
||||
### 2. 공식 소스를 병렬 탐색한다
|
||||
|
||||
최소한 아래 3축을 본다.
|
||||
|
||||
- 한국장학재단 공식 장학 페이지
|
||||
- 사용자 학교 또는 관련 대학군의 공식 장학 공지
|
||||
- 재단/기업/공공기관 공식 공고
|
||||
|
||||
검색 제목이 `장학금` 이 아닐 수 있으니 `장학생 모집`, `외부 장학 추천`, `등록금 감면`, `생활비 지원`, `학업장려비`, `추천장학`, `근로장학`, `성적우수 장학` 도 같이 본다.
|
||||
|
||||
대표 검색 예시:
|
||||
|
||||
- `site:kosaf.go.kr 장학금 {키워드}`
|
||||
- `site:{학교도메인} 장학 공고`
|
||||
- `site:*.ac.kr 장학 공고 {학교명} {전공}`
|
||||
- `site:*.or.kr 장학생 선발 {키워드}`
|
||||
- `site:*.go.kr 장학금 공고 {지역명}`
|
||||
|
||||
### 2-1. 학교/학과 완전 탐색 모드
|
||||
|
||||
사용자가 특정 학교를 주면 아래 순서를 빠뜨리지 않는다.
|
||||
|
||||
1. 학교 대표 도메인 확인
|
||||
2. 학생지원처 / 장학팀 / 학사공지 게시판 확인
|
||||
3. 단과대학 공지 확인
|
||||
4. 학과 / 전공 / 대학원 과정 홈페이지 공지 확인
|
||||
5. 첨부 PDF/HWP가 있으면 같이 열어 조건을 확인
|
||||
6. 교내 장학과 외부 추천 장학을 분리해서 정리
|
||||
|
||||
학교 완전 탐색 체크리스트는 `references/school-discovery.md` 를 본다.
|
||||
|
||||
학교별 search plan을 만들 때는:
|
||||
|
||||
```bash
|
||||
python3 scripts/university_search_plan.py \
|
||||
--school-name "부산대학교" \
|
||||
--department "컴퓨터공학과" \
|
||||
--year 2026
|
||||
```
|
||||
|
||||
전국 대학 sweep query를 만들 때는:
|
||||
|
||||
```bash
|
||||
python3 scripts/university_search_plan.py --nationwide --year 2026
|
||||
```
|
||||
|
||||
### 3. 각 후보를 정규화한다
|
||||
|
||||
후보마다 최소한 아래 필드를 채운다.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "장학금명",
|
||||
"organization": {
|
||||
"name": "운영기관명",
|
||||
"type": "foundation"
|
||||
},
|
||||
"source_url": "https://official.example.com/notice/123",
|
||||
"apply_url": "https://official.example.com/apply",
|
||||
"amount": {
|
||||
"text": "학기당 250만 원",
|
||||
"per_semester_krw": 2500000,
|
||||
"category": "living"
|
||||
},
|
||||
"deadline": {
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-20",
|
||||
"status": "open"
|
||||
},
|
||||
"eligibility": {
|
||||
"student_levels": ["undergraduate"],
|
||||
"school_names": ["서울대학교"],
|
||||
"school_kinds": ["university"],
|
||||
"majors": ["컴퓨터공학", "소프트웨어"],
|
||||
"grade_years": [2, 3, 4],
|
||||
"gpa_min": 3.0,
|
||||
"income_band_min": 0,
|
||||
"income_band_max": 8,
|
||||
"notes": ["직전학기 12학점 이상"]
|
||||
},
|
||||
"verified_at": "2026-04-14",
|
||||
"source_kind": "official"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. helper로 필터링하거나 지원 가능 여부를 본다
|
||||
|
||||
여러 장학금 후보를 JSON으로 정리한 뒤:
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py filter \
|
||||
--input scholarships.json \
|
||||
--org-type foundation \
|
||||
--student-level undergraduate \
|
||||
--income-band 5 \
|
||||
--min-amount 2000000
|
||||
```
|
||||
|
||||
지원 가능 여부 판정:
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py eligibility \
|
||||
--input scholarships.json \
|
||||
--school-name "서울대학교" \
|
||||
--student-level undergraduate \
|
||||
--grade-year 2 \
|
||||
--gpa 3.5 \
|
||||
--income-band 5
|
||||
```
|
||||
|
||||
KST 기준 현재 날짜로 열린 공고만 readable 하게 보기:
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py report \
|
||||
--input scholarships.json \
|
||||
--today 2026-04-14 \
|
||||
--only-open-now \
|
||||
--school-name "서울대학교"
|
||||
```
|
||||
|
||||
마감 임박 공고만 보기:
|
||||
|
||||
```bash
|
||||
python3 scripts/scholarship_filter.py report \
|
||||
--input scholarships.json \
|
||||
--today 2026-04-14 \
|
||||
--deadline-within-days 7
|
||||
```
|
||||
|
||||
### 5. 사용자에게는 compact하게 보여준다
|
||||
|
||||
- 상위 매칭 장학금부터 정리
|
||||
- 장학금명 / 기관 / 금액 / 신청기간 / 핵심 조건 / 링크
|
||||
- 필터 불일치 이유가 있으면 한 줄로 설명
|
||||
- "지원 가능", "조건 일부 미확인", "현재 조건으로는 불일치"를 짧게 표시
|
||||
|
||||
기본 출력 form은 아래 순서를 따른다.
|
||||
|
||||
1. 요약 블록: 총 후보 수 / 열린 공고 수 / 곧 마감 수
|
||||
2. `지금 지원 가능`
|
||||
3. `곧 열림`
|
||||
4. `조건은 맞지만 마감됨`
|
||||
|
||||
각 항목은 이 형식으로 정리한다.
|
||||
|
||||
- 장학금명
|
||||
- 기관명 / 기관 유형
|
||||
- 금액
|
||||
- 신청기간 + KST 기준 현재 날짜 상태 (`open`, `upcoming`, `closed`, `D-3`)
|
||||
- 학교/학과/학년/성적/지원구간 핵심 조건
|
||||
- 공식 공고 링크
|
||||
- 신청 링크
|
||||
|
||||
더 자세한 form 규칙은 `references/report-format.md` 를 본다.
|
||||
|
||||
## Response policy
|
||||
|
||||
- 공식 공고 링크와 신청 링크를 반드시 남긴다.
|
||||
- 금액이 숫자로 안 보이면 원문 텍스트를 그대로 남기고, 추정 금액은 임의로 만들지 않는다.
|
||||
- 블로그/카페/홍보글만 발견되면 공식 공고를 다시 찾고, 못 찾으면 "미검증" 으로 표시한다.
|
||||
- 장학금 마감일은 반드시 절대 날짜로 적는다.
|
||||
- 사용자가 "내 조건으로 걸러줘" 라고 하면 금액, 학교/재단 여부, 학자금 지원구간, 학부/대학원 여부를 우선 필터로 사용한다.
|
||||
- 학자금 지원구간은 한국장학재단 표기를 기준으로 `0~10` 정수 또는 범위로 정규화한다.
|
||||
|
||||
## Keep the answer compact
|
||||
|
||||
- 총 후보 수
|
||||
- 필터 후 남은 후보 수
|
||||
- 상위 5~10개만 표 또는 리스트
|
||||
- 각 항목마다 공식 링크 1개 이상
|
||||
- 필요 시 "추가로 더 좁힐 수 있는 필터" 2~3개 제안
|
||||
|
||||
## Ready-to-paste prompts
|
||||
|
||||
### 1. 전체 탐색
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 지금 신청 가능하거나 곧 열리는 한국 장학금 공고를 찾아줘. 한국장학재단, 전국 대학교, 재단, 기업, 공공기관 공식 공고만 포함하고, KST 기준 현재 날짜로 열린 공고와 곧 열릴 공고를 나눠서 보여줘. 각 항목마다 장학금명, 기관명, 기관 유형, 지원 금액, 신청 기간, 핵심 자격, 학자금 지원구간 조건, 공식 공고 링크, 신청 링크를 가독성 좋은 섹션형 form으로 정리해줘.
|
||||
```
|
||||
|
||||
### 2. 사용자 조건 기반 필터링
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 내 조건에 맞는 장학금만 찾아줘.
|
||||
조건:
|
||||
- 학교: 서울대학교
|
||||
- 학생 구분: 학부생
|
||||
- 학년: 2학년
|
||||
- 전공: 컴퓨터공학
|
||||
- 학자금 지원구간: 5구간
|
||||
- 최소 금액: 200만원
|
||||
- 기관 유형: 재단
|
||||
|
||||
공식 공고만 포함하고, KST 기준 현재 날짜로 마감 여부도 고려해서 지원 가능 여부를 가능/불확실/불가로 표시해줘.
|
||||
```
|
||||
|
||||
### 3. 학교 장학 vs 재단 장학 비교
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 연세대학교 학부생이 지원할 수 있는 교내 장학금과 민간재단 장학금을 나눠서 보여줘. 학교 본부 장학공지, 단과대, 학과 홈페이지 장학 공지를 모두 확인하고, 금액, 신청 기간, 소득구간 조건, 성적 조건, 공식 링크를 같이 정리하고 어느 쪽이 내 조건에 더 맞는지 짧게 비교해줘.
|
||||
```
|
||||
|
||||
### 4. 지원 가능 여부만 빠르게 보기
|
||||
|
||||
```text
|
||||
장학금 검색 및 조회 스킬을 사용해서 아래 프로필로 지원 가능성이 있는 장학금만 골라줘.
|
||||
- 학교: 고려대학교
|
||||
- 학생 구분: 대학원생
|
||||
- 전공: 전산학
|
||||
- GPA: 3.8/4.5
|
||||
- 학자금 지원구간: 4구간
|
||||
- 원하는 유형: 생활비 장학금
|
||||
|
||||
각 항목마다 왜 맞는지 또는 어떤 조건이 불확실한지 한 줄씩 붙여줘.
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- 공식 공고 중심으로 후보를 모았다.
|
||||
- 금액, 자격, 지원구간, 신청기간, 링크를 정리했다.
|
||||
- 사용자가 준 조건으로 필터링했다.
|
||||
- 지원 가능 여부를 빠르게 판단했다.
|
||||
- 비공식 출처는 공식 페이지로 검증했거나 제외했다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 한국장학재단 공식 장학·지원구간 표면은 `references/source-patterns.md` 참고
|
||||
- 학교별 장학 공지는 HTML 구조가 제각각이므로, 공고 제목/본문/첨부를 같이 읽어야 한다.
|
||||
- 장학금 "조건" 과 "우대사항" 을 섞지 않는다.
|
||||
- "등록금 전액" 같은 비정액 장학금은 `amount.text` 를 유지하고 수치가 없으면 `*_krw` 는 비워둔다.
|
||||
40
korean-scholarship-search/references/report-format.md
Normal file
40
korean-scholarship-search/references/report-format.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Report Format
|
||||
|
||||
최종 답변은 changelog처럼 나열하지 말고, 아래 형식을 기본으로 한다.
|
||||
|
||||
## 1. Header summary
|
||||
|
||||
- 검색 기준일
|
||||
- 검색 범위
|
||||
- 총 후보 수
|
||||
- 지금 지원 가능 수
|
||||
- 곧 열릴 공고 수
|
||||
- 마감된 공고 수
|
||||
|
||||
## 2. Grouping
|
||||
|
||||
1. 지금 지원 가능
|
||||
2. 곧 열림
|
||||
3. 조건은 맞지만 마감됨
|
||||
4. 검증 부족 / 미확인
|
||||
|
||||
## 3. Entry format
|
||||
|
||||
각 항목은 아래 순서를 유지한다.
|
||||
|
||||
- 장학금명
|
||||
- 기관명 / 기관 유형
|
||||
- 금액
|
||||
- 신청기간
|
||||
- 상태 (`open`, `upcoming`, `closed`, `D-3`)
|
||||
- 핵심 자격
|
||||
- 지원 가능 여부 (`가능`, `불확실`, `불가`)
|
||||
- 공식 공고 링크
|
||||
- 신청 링크
|
||||
|
||||
## 4. Readability rules
|
||||
|
||||
- 한 항목에 문장을 너무 길게 쓰지 않는다.
|
||||
- 핵심 조건은 `/` 로 짧게 끊는다.
|
||||
- 금액이 불명확하면 `금액 미공개` 라고 적고 공고 원문 링크를 남긴다.
|
||||
- 확실하지 않은 정보는 `미확인` 으로 표시한다.
|
||||
61
korean-scholarship-search/references/school-discovery.md
Normal file
61
korean-scholarship-search/references/school-discovery.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# School Discovery Checklist
|
||||
|
||||
특정 학교를 주면 아래 표면을 순서대로 확인한다.
|
||||
|
||||
이 절차는 서울대 전용이 아니라 전국 모든 `*.ac.kr` 대학에 공통 적용한다.
|
||||
|
||||
## 1. 학교 본부
|
||||
|
||||
- 학생지원처
|
||||
- 장학팀 / 장학복지팀
|
||||
- 학사공지
|
||||
- 일반공지
|
||||
|
||||
검색 예시:
|
||||
|
||||
- `site:{school-domain} 장학 공지`
|
||||
- `site:{school-domain} 학생지원처 장학`
|
||||
- `site:{school-domain} 학사공지 장학`
|
||||
|
||||
## 2. 단과대학
|
||||
|
||||
학교명 + 단과대 이름으로 다시 좁힌다.
|
||||
|
||||
- 공과대학
|
||||
- 인문대학
|
||||
- 사회과학대학
|
||||
- 경영대학
|
||||
- 대학원
|
||||
|
||||
검색 예시:
|
||||
|
||||
- `site:{school-domain} 장학 공과대학`
|
||||
- `site:{school-domain} 장학생 모집 대학원`
|
||||
|
||||
## 3. 학과 / 전공 / 협동과정
|
||||
|
||||
학과 홈페이지가 별도 서브도메인 또는 하위 경로일 수 있다.
|
||||
|
||||
- 학과 공지사항
|
||||
- 학부 공지
|
||||
- 대학원 공지
|
||||
- 외부 장학 추천 공지
|
||||
|
||||
검색 예시:
|
||||
|
||||
- `site:{school-domain} "{department-name}" 장학`
|
||||
- `site:{school-domain} "{department-name}" 공지 장학생`
|
||||
- `site:{school-domain} "{department-name}" 외부 장학`
|
||||
|
||||
## 4. 첨부파일
|
||||
|
||||
- PDF, HWP, DOCX 첨부가 있으면 열어 본다.
|
||||
- 자격/성적/지원구간/금액이 첨부에만 있는 경우가 많다.
|
||||
|
||||
## 5. 정리 원칙
|
||||
|
||||
- `교내 장학`
|
||||
- `학과 장학`
|
||||
- `외부 추천 장학`
|
||||
|
||||
이 세 묶음으로 나눠 보여주면 가독성이 좋아진다.
|
||||
58
korean-scholarship-search/references/search-clues.md
Normal file
58
korean-scholarship-search/references/search-clues.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Search Clues
|
||||
|
||||
장학금 공고는 학교/기관마다 제목이 제각각이라서 `장학금` 한 단어만으로는 누락이 많다.
|
||||
|
||||
## 1. Core keywords
|
||||
|
||||
- 장학
|
||||
- 장학금
|
||||
- 장학생
|
||||
- 장학생 선발
|
||||
- 장학생 모집
|
||||
- 장학 안내
|
||||
- 장학 공고
|
||||
- 장학 신청
|
||||
|
||||
## 2. University-specific clues
|
||||
|
||||
- 교내 장학
|
||||
- 학과 장학
|
||||
- 외부 장학
|
||||
- 외부 장학 추천
|
||||
- 추천장학
|
||||
- 등록금 감면
|
||||
- 등록금 면제
|
||||
- 생활비 지원
|
||||
- 학업장려비
|
||||
- 근로장학
|
||||
- 성적우수 장학
|
||||
- 신입생 장학
|
||||
- 계속장학생
|
||||
- 복지 장학
|
||||
|
||||
## 3. Organization-specific clues
|
||||
|
||||
- 재단: 장학생 선발, 장학생 모집, 지원사업, 교육지원
|
||||
- 기업: 인재육성, 장학생 모집, CSR 장학, 사회공헌 장학
|
||||
- 지자체: 대학생 장학금, 지역인재 장학금, 주민등록 요건, 거주요건
|
||||
- 한국장학재단: 국가장학금, 푸른등대, 대통령과학장학금, 국가우수장학금, 국가근로장학금
|
||||
|
||||
## 4. Attachment clues
|
||||
|
||||
본문보다 첨부파일에 핵심 조건이 있는 경우가 많다.
|
||||
|
||||
- 모집요강
|
||||
- 선발요강
|
||||
- 신청서식
|
||||
- 제출서류
|
||||
- 추천서
|
||||
- 개인정보동의서
|
||||
|
||||
## 5. Query expansion examples
|
||||
|
||||
- `site:*.ac.kr 외부 장학 추천`
|
||||
- `site:*.ac.kr 등록금 감면`
|
||||
- `site:*.ac.kr 생활비 지원 장학`
|
||||
- `site:*.ac.kr 장학생 선발`
|
||||
- `site:*.or.kr 장학생 모집`
|
||||
- `site:*.go.kr 지역인재 장학금`
|
||||
67
korean-scholarship-search/references/source-patterns.md
Normal file
67
korean-scholarship-search/references/source-patterns.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Scholarship Source Patterns
|
||||
|
||||
한국 장학금 검색은 항상 **공식 공고 우선** 이다.
|
||||
|
||||
## 1. Source priority
|
||||
|
||||
1. 한국장학재단 (`kosaf.go.kr`)
|
||||
2. 대학 공식 장학/학사/학생지원 공지 (`*.ac.kr`)
|
||||
3. 지자체/공공기관 (`*.go.kr`, `*.or.kr`)
|
||||
4. 재단/기업 공식 공고
|
||||
5. 비공식 모음글은 lead source 로만 사용
|
||||
|
||||
## 2. Search query templates
|
||||
|
||||
다음 쿼리를 현재 날짜 기준으로 조합한다. 추가 키워드 단서는 `search-clues.md` 를 같이 본다.
|
||||
|
||||
- `site:kosaf.go.kr 장학금 {키워드}`
|
||||
- `site:kosaf.go.kr 푸른등대 {키워드}`
|
||||
- `site:*.ac.kr 장학 공고 {학교명}`
|
||||
- `site:*.ac.kr 장학생 모집 {학교명} {전공}`
|
||||
- `site:*.or.kr 장학생 선발 {키워드}`
|
||||
- `site:*.go.kr 장학금 공고 {지역명}`
|
||||
- `site:{재단도메인} 장학생 모집`
|
||||
|
||||
특정 학교 완전 탐색이 필요하면 `school-discovery.md` 절차를 같이 따른다.
|
||||
|
||||
## 3. What to extract from each notice
|
||||
|
||||
- 장학금명
|
||||
- 운영기관명
|
||||
- 기관 유형
|
||||
- 공고일 / 신청 시작일 / 마감일
|
||||
- 지원 금액 / 등록금형 / 생활비형 / 혼합형
|
||||
- 지원 대상: 학교, 학부/대학원, 학년, 전공, 지역
|
||||
- 성적 조건
|
||||
- 학자금 지원구간 조건
|
||||
- 제출서류
|
||||
- 신청 방식
|
||||
- 공식 공고 링크
|
||||
- 공식 신청 링크
|
||||
|
||||
## 4. Organization type normalization
|
||||
|
||||
- `school`: 대학/대학원/고교 교내 장학
|
||||
- `foundation`: 민간재단, 복지재단, 교육재단
|
||||
- `government`: 중앙정부/공공기관
|
||||
- `local-government`: 광역·기초지자체
|
||||
- `company`: 기업/기업재단/CSR
|
||||
- `other`: 위 분류가 애매한 경우
|
||||
|
||||
## 5. Eligibility normalization
|
||||
|
||||
가능하면 아래 필드로 구조화한다.
|
||||
|
||||
- `student_levels`: `highschool`, `undergraduate`, `graduate`, `all`
|
||||
- `school_kinds`: `highschool`, `college`, `university`, `graduate-school`
|
||||
- `grade_years`: 숫자 배열
|
||||
- `majors`: 문자열 배열
|
||||
- `gpa_min`: 4.5 또는 100점 기준이 섞여 있으면 원문도 같이 남긴다
|
||||
- `income_band_min`, `income_band_max`
|
||||
- `notes`: 구조화가 애매한 자격 조건
|
||||
|
||||
## 6. Verification rule
|
||||
|
||||
- 비공식 요약 페이지는 링크 탐색용으로만 본다.
|
||||
- 최종 결과에는 공식 공고 링크를 반드시 포함한다.
|
||||
- 공고 날짜가 오래됐으면 최신 회차/학기 공고를 다시 찾는다.
|
||||
811
korean-scholarship-search/scripts/scholarship_filter.py
Normal file
811
korean-scholarship-search/scripts/scholarship_filter.py
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Filter normalized Korean scholarship records and estimate eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
AMOUNT_KEYS = (
|
||||
"annual_krw",
|
||||
"per_semester_krw",
|
||||
"one_time_krw",
|
||||
"monthly_krw",
|
||||
"max_krw",
|
||||
"min_krw",
|
||||
"amount_krw",
|
||||
)
|
||||
CANONICAL_DEADLINE_STATUSES = {"open", "upcoming", "closed", "unknown"}
|
||||
KST = timezone(timedelta(hours=9), name="Asia/Seoul")
|
||||
KST_LABEL = "Asia/Seoul (KST)"
|
||||
|
||||
|
||||
def read_payload(path: str | None) -> Any:
|
||||
if path:
|
||||
return json.loads(Path(path).read_text(encoding="utf8"))
|
||||
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
raise SystemExit("expected JSON input from --input or stdin")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def ensure_records(payload: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
if isinstance(payload.get("items"), list):
|
||||
return [item for item in payload["items"] if isinstance(item, dict)]
|
||||
return [payload]
|
||||
raise SystemExit("input JSON must be an object or an array of objects")
|
||||
|
||||
|
||||
def as_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
|
||||
|
||||
def normalize_text(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def contains_text(values: list[Any], needle: str) -> bool:
|
||||
target = normalize_text(needle)
|
||||
return any(target in normalize_text(value) for value in values)
|
||||
|
||||
|
||||
def parse_int(value: Any) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
try:
|
||||
return int(str(value).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_float(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return float(str(value).replace(",", "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_date(value: Any) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d", "%Y%m%d"):
|
||||
try:
|
||||
if fmt == "%Y%m%d" and len(text) != 8:
|
||||
continue
|
||||
if fmt == "%Y-%m-%d":
|
||||
parts = text.split("-")
|
||||
if len(parts) == 3:
|
||||
return date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
if fmt == "%Y.%m.%d":
|
||||
parts = text.split(".")
|
||||
if len(parts) == 3:
|
||||
return date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
if fmt == "%Y/%m/%d":
|
||||
parts = text.split("/")
|
||||
if len(parts) == 3:
|
||||
return date(int(parts[0]), int(parts[1]), int(parts[2]))
|
||||
if fmt == "%Y%m%d":
|
||||
return date(int(text[0:4]), int(text[4:6]), int(text[6:8]))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def current_kst_date(now: datetime | None = None) -> date:
|
||||
if now is None:
|
||||
return datetime.now(KST).date()
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
return now.astimezone(KST).date()
|
||||
|
||||
|
||||
def resolve_today(value: str | None) -> date:
|
||||
parsed = parse_date(value)
|
||||
return parsed or current_kst_date()
|
||||
|
||||
|
||||
def extract_org_type(record: dict[str, Any]) -> str:
|
||||
organization = record.get("organization") or {}
|
||||
return normalize_text(record.get("org_type") or organization.get("type") or "")
|
||||
|
||||
|
||||
def extract_org_name(record: dict[str, Any]) -> str:
|
||||
organization = record.get("organization") or {}
|
||||
return str(record.get("organization_name") or organization.get("name") or "").strip()
|
||||
|
||||
|
||||
def parse_amount_from_text(text: str) -> list[int]:
|
||||
candidates: list[int] = []
|
||||
for raw, unit in re.findall(r"([0-9][0-9,]*(?:\.[0-9]+)?)\s*(만원|원)", text):
|
||||
try:
|
||||
value = float(raw.replace(",", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
multiplier = 10000 if unit == "만원" else 1
|
||||
candidates.append(int(value * multiplier))
|
||||
return candidates
|
||||
|
||||
|
||||
def normalize_deadline_status(value: Any) -> str | None:
|
||||
status = normalize_text(value)
|
||||
if status in CANONICAL_DEADLINE_STATUSES:
|
||||
return status
|
||||
return None
|
||||
|
||||
|
||||
def extract_amount_value(record: dict[str, Any]) -> int | None:
|
||||
amount = record.get("amount")
|
||||
candidates: list[int] = []
|
||||
|
||||
if isinstance(amount, dict):
|
||||
for key in AMOUNT_KEYS:
|
||||
parsed = parse_int(amount.get(key))
|
||||
if parsed is not None:
|
||||
candidates.append(parsed)
|
||||
text = str(amount.get("text") or "")
|
||||
candidates.extend(parse_amount_from_text(text))
|
||||
else:
|
||||
parsed = parse_int(record.get("amount_krw"))
|
||||
if parsed is not None:
|
||||
candidates.append(parsed)
|
||||
if isinstance(amount, str):
|
||||
candidates.extend(parse_amount_from_text(amount))
|
||||
text = str(record.get("amount_text") or "")
|
||||
candidates.extend(parse_amount_from_text(text))
|
||||
|
||||
return max(candidates) if candidates else None
|
||||
|
||||
|
||||
def infer_deadline_status(record: dict[str, Any], today: date | None = None) -> str:
|
||||
today = today or current_kst_date()
|
||||
deadline = record.get("deadline") or {}
|
||||
start_at = parse_date(deadline.get("start"))
|
||||
end_at = parse_date(deadline.get("end"))
|
||||
|
||||
if end_at and end_at < today:
|
||||
return "closed"
|
||||
if start_at and start_at > today:
|
||||
return "upcoming"
|
||||
if end_at and end_at >= today:
|
||||
return "open"
|
||||
cached_status = normalize_deadline_status(deadline.get("status") or record.get("deadline_status"))
|
||||
return cached_status or "unknown"
|
||||
|
||||
|
||||
def deadline_context(record: dict[str, Any], today: date | None = None) -> dict[str, Any]:
|
||||
today = today or current_kst_date()
|
||||
deadline = record.get("deadline") or {}
|
||||
start_at = parse_date(deadline.get("start"))
|
||||
end_at = parse_date(deadline.get("end"))
|
||||
status = infer_deadline_status(record, today)
|
||||
days_until_start = (start_at - today).days if start_at else None
|
||||
days_until_end = (end_at - today).days if end_at else None
|
||||
return {
|
||||
"today": today.isoformat(),
|
||||
"start": start_at.isoformat() if start_at else None,
|
||||
"end": end_at.isoformat() if end_at else None,
|
||||
"status": status,
|
||||
"days_until_start": days_until_start,
|
||||
"days_until_end": days_until_end,
|
||||
}
|
||||
|
||||
|
||||
def get_eligibility(record: dict[str, Any]) -> dict[str, Any]:
|
||||
eligibility = record.get("eligibility")
|
||||
if isinstance(eligibility, dict):
|
||||
return eligibility
|
||||
return {}
|
||||
|
||||
|
||||
def extract_department_names(record: dict[str, Any]) -> list[Any]:
|
||||
eligibility = get_eligibility(record)
|
||||
values = as_list(eligibility.get("department_names"))
|
||||
if values:
|
||||
return values
|
||||
return as_list(eligibility.get("majors"))
|
||||
|
||||
|
||||
def school_match_values(record: dict[str, Any]) -> list[Any]:
|
||||
eligibility = get_eligibility(record)
|
||||
values: list[Any] = []
|
||||
values.extend(as_list(eligibility.get("school_names")))
|
||||
values.append(extract_org_name(record))
|
||||
return [value for value in values if value]
|
||||
|
||||
|
||||
def department_match_values(record: dict[str, Any]) -> list[Any]:
|
||||
values: list[Any] = []
|
||||
values.extend(extract_department_names(record))
|
||||
values.append(extract_org_name(record))
|
||||
values.append(record.get("source_url"))
|
||||
values.append(record.get("summary"))
|
||||
return [value for value in values if value]
|
||||
|
||||
|
||||
def match_query(record: dict[str, Any], query: str | None) -> bool:
|
||||
if not query:
|
||||
return True
|
||||
|
||||
haystacks = [
|
||||
record.get("name"),
|
||||
extract_org_name(record),
|
||||
record.get("summary"),
|
||||
record.get("notes"),
|
||||
record.get("source_url"),
|
||||
record.get("apply_url"),
|
||||
]
|
||||
eligibility = get_eligibility(record)
|
||||
haystacks.extend(as_list(eligibility.get("majors")))
|
||||
haystacks.extend(extract_department_names(record))
|
||||
haystacks.extend(as_list(eligibility.get("notes")))
|
||||
return contains_text(haystacks, query)
|
||||
|
||||
|
||||
def match_filter(record: dict[str, Any], args: argparse.Namespace) -> tuple[bool, list[str]]:
|
||||
reasons: list[str] = []
|
||||
eligibility = get_eligibility(record)
|
||||
today = resolve_today(getattr(args, "today", None))
|
||||
|
||||
if not match_query(record, args.q):
|
||||
return False, reasons
|
||||
|
||||
if args.org_type:
|
||||
org_type = extract_org_type(record)
|
||||
if org_type != normalize_text(args.org_type):
|
||||
return False, reasons
|
||||
reasons.append(f"org_type={org_type}")
|
||||
|
||||
context = deadline_context(record, today)
|
||||
|
||||
if args.deadline_status:
|
||||
deadline_status = context["status"]
|
||||
if deadline_status != normalize_text(args.deadline_status):
|
||||
return False, reasons
|
||||
reasons.append(f"deadline_status={deadline_status}")
|
||||
|
||||
if getattr(args, "only_open_now", False):
|
||||
if context["status"] != "open":
|
||||
return False, reasons
|
||||
reasons.append("only_open_now")
|
||||
|
||||
upcoming_within_days = getattr(args, "upcoming_within_days", None)
|
||||
if upcoming_within_days is not None:
|
||||
days_until_start = context["days_until_start"]
|
||||
if context["status"] != "upcoming" or days_until_start is None or days_until_start < 0 or days_until_start > upcoming_within_days:
|
||||
return False, reasons
|
||||
reasons.append(f"upcoming_within_days<={upcoming_within_days}")
|
||||
|
||||
deadline_within_days = getattr(args, "deadline_within_days", None)
|
||||
if deadline_within_days is not None:
|
||||
days_until_end = context["days_until_end"]
|
||||
if days_until_end is None or days_until_end < 0 or days_until_end > deadline_within_days:
|
||||
return False, reasons
|
||||
reasons.append(f"deadline_within_days<={deadline_within_days}")
|
||||
|
||||
if args.school_kind:
|
||||
school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
|
||||
if school_kinds and normalize_text(args.school_kind) not in school_kinds:
|
||||
return False, reasons
|
||||
if school_kinds:
|
||||
reasons.append(f"school_kind={args.school_kind}")
|
||||
else:
|
||||
reasons.append("school_kind=?")
|
||||
|
||||
if args.school_name:
|
||||
school_names = school_match_values(record)
|
||||
if school_names and not contains_text(school_names, args.school_name):
|
||||
return False, reasons
|
||||
if school_names:
|
||||
reasons.append(f"school_name~={args.school_name}")
|
||||
else:
|
||||
reasons.append("school_name=?")
|
||||
|
||||
if args.student_level:
|
||||
student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
|
||||
if student_levels and normalize_text(args.student_level) not in student_levels:
|
||||
return False, reasons
|
||||
if student_levels:
|
||||
reasons.append(f"student_level={args.student_level}")
|
||||
else:
|
||||
reasons.append("student_level=?")
|
||||
|
||||
if args.major:
|
||||
majors = as_list(eligibility.get("majors"))
|
||||
if majors and not contains_text(majors, args.major):
|
||||
return False, reasons
|
||||
if majors:
|
||||
reasons.append(f"major~={args.major}")
|
||||
else:
|
||||
reasons.append("major=?")
|
||||
|
||||
if getattr(args, "department_name", None):
|
||||
departments = department_match_values(record)
|
||||
if departments and not contains_text(departments, args.department_name):
|
||||
return False, reasons
|
||||
if departments:
|
||||
reasons.append(f"department_name~={args.department_name}")
|
||||
else:
|
||||
reasons.append("department_name=?")
|
||||
|
||||
if args.grade_year is not None:
|
||||
grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
|
||||
grade_years.discard(None)
|
||||
if grade_years and args.grade_year not in grade_years:
|
||||
return False, reasons
|
||||
if grade_years:
|
||||
reasons.append(f"grade_year={args.grade_year}")
|
||||
else:
|
||||
reasons.append("grade_year=?")
|
||||
|
||||
if args.gpa is not None:
|
||||
gpa_min = parse_float(eligibility.get("gpa_min"))
|
||||
if gpa_min is not None and args.gpa < gpa_min:
|
||||
return False, reasons
|
||||
if gpa_min is not None:
|
||||
reasons.append(f"gpa>={gpa_min}")
|
||||
else:
|
||||
reasons.append("gpa=?")
|
||||
|
||||
if args.income_band is not None:
|
||||
income_band_min = parse_int(eligibility.get("income_band_min"))
|
||||
income_band_max = parse_int(eligibility.get("income_band_max"))
|
||||
income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
|
||||
income_bands.discard(None)
|
||||
|
||||
if income_bands and args.income_band not in income_bands:
|
||||
return False, reasons
|
||||
if income_band_min is not None and args.income_band < income_band_min:
|
||||
return False, reasons
|
||||
if income_band_max is not None and args.income_band > income_band_max:
|
||||
return False, reasons
|
||||
if income_bands or income_band_min is not None or income_band_max is not None:
|
||||
reasons.append(f"income_band={args.income_band}")
|
||||
else:
|
||||
reasons.append("income_band=?")
|
||||
|
||||
amount_value = extract_amount_value(record)
|
||||
if args.min_amount is not None:
|
||||
if amount_value is not None and amount_value < args.min_amount:
|
||||
return False, reasons
|
||||
if amount_value is None:
|
||||
if getattr(args, "strict_amount", False):
|
||||
return False, reasons
|
||||
reasons.append(f"amount>={args.min_amount}?")
|
||||
else:
|
||||
reasons.append(f"amount>={args.min_amount}")
|
||||
if args.max_amount is not None:
|
||||
if amount_value is not None and amount_value > args.max_amount:
|
||||
return False, reasons
|
||||
if amount_value is None:
|
||||
if getattr(args, "strict_amount", False):
|
||||
return False, reasons
|
||||
reasons.append(f"amount<={args.max_amount}?")
|
||||
else:
|
||||
reasons.append(f"amount<={args.max_amount}")
|
||||
|
||||
return True, reasons
|
||||
|
||||
|
||||
def command_filter(args: argparse.Namespace) -> int:
|
||||
records = ensure_records(read_payload(args.input))
|
||||
items: list[dict[str, Any]] = []
|
||||
today = resolve_today(getattr(args, "today", None))
|
||||
|
||||
for record in records:
|
||||
matched, reasons = match_filter(record, args)
|
||||
if not matched:
|
||||
continue
|
||||
entry = deepcopy(record)
|
||||
entry["_match"] = {
|
||||
"amount_krw": extract_amount_value(record),
|
||||
"deadline": deadline_context(record, today),
|
||||
"deadline_status": infer_deadline_status(record, today),
|
||||
"reasons": reasons,
|
||||
}
|
||||
items.append(entry)
|
||||
|
||||
payload = {
|
||||
"filters": {
|
||||
"q": args.q,
|
||||
"org_type": args.org_type,
|
||||
"school_kind": args.school_kind,
|
||||
"school_name": args.school_name,
|
||||
"student_level": args.student_level,
|
||||
"major": args.major,
|
||||
"department_name": args.department_name,
|
||||
"grade_year": args.grade_year,
|
||||
"gpa": args.gpa,
|
||||
"income_band": args.income_band,
|
||||
"min_amount": args.min_amount,
|
||||
"max_amount": args.max_amount,
|
||||
"deadline_status": args.deadline_status,
|
||||
"today": args.today,
|
||||
"only_open_now": args.only_open_now,
|
||||
"upcoming_within_days": args.upcoming_within_days,
|
||||
"deadline_within_days": args.deadline_within_days,
|
||||
},
|
||||
"total": len(items),
|
||||
"items": items,
|
||||
}
|
||||
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def eligibility_result(record: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
||||
failed: list[str] = []
|
||||
passed: list[str] = []
|
||||
unknown: list[str] = []
|
||||
eligibility = get_eligibility(record)
|
||||
|
||||
if args.org_type:
|
||||
org_type = extract_org_type(record)
|
||||
if not org_type:
|
||||
unknown.append("org_type=?")
|
||||
elif org_type == normalize_text(args.org_type):
|
||||
passed.append(f"org_type={org_type}")
|
||||
else:
|
||||
failed.append(f"org_type mismatch: {org_type or 'unknown'}")
|
||||
|
||||
if args.school_kind:
|
||||
school_kinds = [normalize_text(value) for value in as_list(eligibility.get("school_kinds"))]
|
||||
if school_kinds and normalize_text(args.school_kind) not in school_kinds:
|
||||
failed.append(f"school_kind mismatch: {school_kinds}")
|
||||
elif school_kinds:
|
||||
passed.append(f"school_kind={args.school_kind}")
|
||||
else:
|
||||
unknown.append("school_kind=?")
|
||||
|
||||
if args.school_name:
|
||||
school_names = school_match_values(record)
|
||||
if school_names and not contains_text(school_names, args.school_name):
|
||||
failed.append(f"school_name mismatch: {school_names}")
|
||||
elif school_names:
|
||||
passed.append(f"school_name~={args.school_name}")
|
||||
else:
|
||||
unknown.append("school_name=?")
|
||||
|
||||
if args.student_level:
|
||||
student_levels = [normalize_text(value) for value in as_list(eligibility.get("student_levels"))]
|
||||
if student_levels and normalize_text(args.student_level) not in student_levels:
|
||||
failed.append(f"student_level mismatch: {student_levels}")
|
||||
elif student_levels:
|
||||
passed.append(f"student_level={args.student_level}")
|
||||
else:
|
||||
unknown.append("student_level=?")
|
||||
|
||||
if args.major:
|
||||
majors = as_list(eligibility.get("majors"))
|
||||
if majors and not contains_text(majors, args.major):
|
||||
failed.append(f"major mismatch: {majors}")
|
||||
elif majors:
|
||||
passed.append(f"major~={args.major}")
|
||||
else:
|
||||
unknown.append("major=?")
|
||||
|
||||
if getattr(args, "department_name", None):
|
||||
departments = department_match_values(record)
|
||||
if departments and not contains_text(departments, args.department_name):
|
||||
failed.append(f"department_name mismatch: {departments}")
|
||||
elif departments:
|
||||
passed.append(f"department_name~={args.department_name}")
|
||||
else:
|
||||
unknown.append("department_name=?")
|
||||
|
||||
if args.grade_year is not None:
|
||||
grade_years = {parse_int(value) for value in as_list(eligibility.get("grade_years"))}
|
||||
grade_years.discard(None)
|
||||
if grade_years and args.grade_year not in grade_years:
|
||||
failed.append(f"grade_year mismatch: {sorted(grade_years)}")
|
||||
elif grade_years:
|
||||
passed.append(f"grade_year={args.grade_year}")
|
||||
else:
|
||||
unknown.append("grade_year=?")
|
||||
|
||||
if args.gpa is not None:
|
||||
gpa_min = parse_float(eligibility.get("gpa_min"))
|
||||
if gpa_min is not None and args.gpa < gpa_min:
|
||||
failed.append(f"gpa below minimum: {gpa_min}")
|
||||
elif gpa_min is not None:
|
||||
passed.append(f"gpa>={gpa_min}")
|
||||
else:
|
||||
unknown.append("gpa=?")
|
||||
|
||||
if args.income_band is not None:
|
||||
income_band_min = parse_int(eligibility.get("income_band_min"))
|
||||
income_band_max = parse_int(eligibility.get("income_band_max"))
|
||||
income_bands = {parse_int(value) for value in as_list(eligibility.get("income_bands"))}
|
||||
income_bands.discard(None)
|
||||
|
||||
if income_bands and args.income_band not in income_bands:
|
||||
failed.append(f"income_band mismatch: {sorted(income_bands)}")
|
||||
elif income_band_min is not None and args.income_band < income_band_min:
|
||||
failed.append(f"income_band below minimum: {income_band_min}")
|
||||
elif income_band_max is not None and args.income_band > income_band_max:
|
||||
failed.append(f"income_band above maximum: {income_band_max}")
|
||||
elif income_bands or income_band_min is not None or income_band_max is not None:
|
||||
passed.append(f"income_band={args.income_band}")
|
||||
else:
|
||||
unknown.append("income_band=?")
|
||||
|
||||
if failed:
|
||||
status = "not_eligible"
|
||||
elif unknown:
|
||||
status = "indeterminate"
|
||||
elif passed:
|
||||
status = "eligible"
|
||||
else:
|
||||
status = "indeterminate"
|
||||
return {
|
||||
"name": record.get("name"),
|
||||
"organization_name": extract_org_name(record),
|
||||
"organization_type": extract_org_type(record),
|
||||
"source_url": record.get("source_url"),
|
||||
"apply_url": record.get("apply_url"),
|
||||
"status": status,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"unknown": unknown,
|
||||
}
|
||||
|
||||
|
||||
def command_eligibility(args: argparse.Namespace) -> int:
|
||||
records = ensure_records(read_payload(args.input))
|
||||
results = [eligibility_result(record, args) for record in records]
|
||||
payload = {
|
||||
"profile": {
|
||||
"org_type": args.org_type,
|
||||
"school_kind": args.school_kind,
|
||||
"school_name": args.school_name,
|
||||
"student_level": args.student_level,
|
||||
"major": args.major,
|
||||
"department_name": args.department_name,
|
||||
"grade_year": args.grade_year,
|
||||
"gpa": args.gpa,
|
||||
"income_band": args.income_band,
|
||||
},
|
||||
"total": len(results),
|
||||
"results": results,
|
||||
}
|
||||
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def add_common_filters(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--input", help="JSON file path. If omitted, reads from stdin.")
|
||||
parser.add_argument("--q", help="Keyword filter across name, organization, notes, and majors.")
|
||||
parser.add_argument("--org-type", help="school|foundation|government|company|local-government|other")
|
||||
parser.add_argument("--school-kind", help="highschool|college|university|graduate-school")
|
||||
parser.add_argument("--school-name", help="Partial match against supported school names.")
|
||||
parser.add_argument("--student-level", help="highschool|undergraduate|graduate|all")
|
||||
parser.add_argument("--major", help="Partial match against target major names.")
|
||||
parser.add_argument("--department-name", help="Partial match against department/program names.")
|
||||
parser.add_argument("--grade-year", type=int, help="Student year, e.g. 1, 2, 3, 4.")
|
||||
parser.add_argument("--gpa", type=float, help="Current GPA for eligibility check.")
|
||||
parser.add_argument("--income-band", type=int, help="학자금 지원구간 integer, usually 0~10.")
|
||||
parser.add_argument(
|
||||
"--today",
|
||||
help=f"Override current date for deadline filtering/reporting. When omitted or unparsable, defaults to current KST date ({KST_LABEL}), e.g. 2026-04-14.",
|
||||
)
|
||||
|
||||
|
||||
def format_krw(value: int | None) -> str:
|
||||
if value is None:
|
||||
return "미공개"
|
||||
if value >= 100000000:
|
||||
return f"{value / 100000000:.1f}억 원"
|
||||
if value >= 10000:
|
||||
return f"{value / 10000:.0f}만 원"
|
||||
return f"{value:,}원"
|
||||
|
||||
|
||||
def compact_eligibility_text(record: dict[str, Any]) -> str:
|
||||
eligibility = get_eligibility(record)
|
||||
chunks: list[str] = []
|
||||
|
||||
school_names = as_list(eligibility.get("school_names"))
|
||||
if school_names:
|
||||
chunks.append("학교 " + ", ".join(map(str, school_names[:3])))
|
||||
|
||||
departments = extract_department_names(record)
|
||||
if departments:
|
||||
chunks.append("학과/전공 " + ", ".join(map(str, departments[:3])))
|
||||
|
||||
student_levels = as_list(eligibility.get("student_levels"))
|
||||
if student_levels:
|
||||
chunks.append("학생구분 " + ", ".join(map(str, student_levels)))
|
||||
|
||||
grade_years = [str(value) for value in as_list(eligibility.get("grade_years")) if value is not None]
|
||||
if grade_years:
|
||||
chunks.append("학년 " + ", ".join(grade_years))
|
||||
|
||||
gpa_min = eligibility.get("gpa_min")
|
||||
if gpa_min not in (None, ""):
|
||||
chunks.append(f"GPA {gpa_min} 이상")
|
||||
|
||||
income_band_min = eligibility.get("income_band_min")
|
||||
income_band_max = eligibility.get("income_band_max")
|
||||
if income_band_min not in (None, "") or income_band_max not in (None, ""):
|
||||
if income_band_min not in (None, "") and income_band_max not in (None, ""):
|
||||
chunks.append(f"지원구간 {income_band_min}~{income_band_max}")
|
||||
elif income_band_max not in (None, ""):
|
||||
chunks.append(f"지원구간 {income_band_max} 이하")
|
||||
else:
|
||||
chunks.append(f"지원구간 {income_band_min} 이상")
|
||||
|
||||
notes = as_list(eligibility.get("notes"))
|
||||
if notes:
|
||||
chunks.append(str(notes[0]))
|
||||
|
||||
return " / ".join(chunks) if chunks else "세부 자격은 공고 원문 확인"
|
||||
|
||||
|
||||
def report_entry(record: dict[str, Any], today: date) -> str:
|
||||
match_meta = record.get("_match") if isinstance(record.get("_match"), dict) else {}
|
||||
context = match_meta.get("deadline") if isinstance(match_meta.get("deadline"), dict) else deadline_context(record, today)
|
||||
amount_text = None
|
||||
if isinstance(record.get("amount"), dict):
|
||||
amount_text = record["amount"].get("text")
|
||||
if not amount_text:
|
||||
amount_text = format_krw(extract_amount_value(record))
|
||||
|
||||
status_label = context["status"]
|
||||
if context["days_until_end"] is not None and context["days_until_end"] >= 0:
|
||||
status_label = f"{status_label} / D-{context['days_until_end']}"
|
||||
elif context["days_until_start"] is not None and context["days_until_start"] >= 0 and context["status"] == "upcoming":
|
||||
status_label = f"{status_label} / starts in {context['days_until_start']}d"
|
||||
|
||||
organization_name = extract_org_name(record) or "기관명 미상"
|
||||
organization_type = extract_org_type(record) or "unknown"
|
||||
period = f"{context['start'] or '?'} ~ {context['end'] or '?'}"
|
||||
source_url = record.get("source_url") or "-"
|
||||
apply_url = record.get("apply_url") or "-"
|
||||
reasons = match_meta.get("reasons") if isinstance(match_meta.get("reasons"), list) else []
|
||||
|
||||
lines = [
|
||||
f"### {record.get('name') or '장학금명 미상'}",
|
||||
f"- 기관: {organization_name} ({organization_type})",
|
||||
f"- 금액: {amount_text}",
|
||||
f"- 기간: {period}",
|
||||
f"- 상태: {status_label}",
|
||||
f"- 핵심 조건: {compact_eligibility_text(record)}",
|
||||
]
|
||||
if reasons:
|
||||
lines.append(f"- 필터 판정: {', '.join(reasons)}")
|
||||
lines.extend(
|
||||
[
|
||||
f"- 공식 공고: {source_url}",
|
||||
f"- 신청 링크: {apply_url}",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def command_report(args: argparse.Namespace) -> int:
|
||||
today = resolve_today(args.today)
|
||||
records = ensure_records(read_payload(args.input))
|
||||
matched: list[dict[str, Any]] = []
|
||||
|
||||
for record in records:
|
||||
ok, reasons = match_filter(record, args)
|
||||
if ok:
|
||||
entry = deepcopy(record)
|
||||
entry["_match"] = {
|
||||
"amount_krw": extract_amount_value(record),
|
||||
"deadline": deadline_context(record, today),
|
||||
"deadline_status": infer_deadline_status(record, today),
|
||||
"reasons": reasons,
|
||||
}
|
||||
matched.append(entry)
|
||||
|
||||
groups = {"open": [], "upcoming": [], "closed": [], "unknown": []}
|
||||
for record in matched:
|
||||
status = normalize_deadline_status((record.get("_match") or {}).get("deadline_status")) or "unknown"
|
||||
groups[status].append(record)
|
||||
|
||||
lines = [
|
||||
"# 장학금 검색 및 조회 리포트",
|
||||
f"- 기준일: {today.isoformat()} ({KST_LABEL})",
|
||||
f"- 총 후보 수: {len(matched)}",
|
||||
f"- 지금 지원 가능: {len(groups['open'])}",
|
||||
f"- 곧 열림: {len(groups['upcoming'])}",
|
||||
f"- 마감됨: {len(groups['closed'])}",
|
||||
f"- 상태 미확인: {len(groups['unknown'])}",
|
||||
"",
|
||||
]
|
||||
|
||||
sections = [
|
||||
("지금 지원 가능", "open"),
|
||||
("곧 열림", "upcoming"),
|
||||
("마감됨", "closed"),
|
||||
("상태 미확인", "unknown"),
|
||||
]
|
||||
for title, key in sections:
|
||||
if not groups[key]:
|
||||
continue
|
||||
lines.append(f"## {title}")
|
||||
lines.append("")
|
||||
for record in groups[key]:
|
||||
lines.append(report_entry(record, today))
|
||||
lines.append("")
|
||||
|
||||
sys.stdout.write("\n".join(lines).rstrip() + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Filter normalized Korean scholarship records and estimate eligibility from structured JSON.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
filter_parser = subparsers.add_parser("filter", help="Filter scholarship records by profile and preference.")
|
||||
add_common_filters(filter_parser)
|
||||
filter_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
|
||||
filter_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
|
||||
filter_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
|
||||
filter_parser.add_argument("--deadline-status", help="open|upcoming|closed")
|
||||
filter_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
|
||||
filter_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
|
||||
filter_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
|
||||
filter_parser.set_defaults(func=command_filter)
|
||||
|
||||
eligibility_parser = subparsers.add_parser(
|
||||
"eligibility",
|
||||
help="Return eligible/not_eligible verdicts for each scholarship record.",
|
||||
)
|
||||
add_common_filters(eligibility_parser)
|
||||
eligibility_parser.set_defaults(func=command_eligibility)
|
||||
|
||||
report_parser = subparsers.add_parser(
|
||||
"report",
|
||||
help="Render a readable markdown report grouped by open/upcoming/closed based on the current date.",
|
||||
)
|
||||
add_common_filters(report_parser)
|
||||
report_parser.add_argument("--min-amount", type=int, help="Minimum scholarship amount in KRW.")
|
||||
report_parser.add_argument("--max-amount", type=int, help="Maximum scholarship amount in KRW.")
|
||||
report_parser.add_argument("--strict-amount", action="store_true", help="Drop records whose amount cannot be normalized to KRW.")
|
||||
report_parser.add_argument("--deadline-status", help="open|upcoming|closed")
|
||||
report_parser.add_argument("--only-open-now", action="store_true", help="Keep only scholarships open on --today.")
|
||||
report_parser.add_argument("--upcoming-within-days", type=int, help="Keep scholarships opening within N days.")
|
||||
report_parser.add_argument("--deadline-within-days", type=int, help="Keep scholarships closing within N days.")
|
||||
report_parser.set_defaults(func=command_report)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
224
korean-scholarship-search/scripts/test_scholarship_filter.py
Normal file
224
korean-scholarship-search/scripts/test_scholarship_filter.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from argparse import Namespace
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
FILTER_PATH = SCRIPT_DIR / "scholarship_filter.py"
|
||||
PLANNER_PATH = SCRIPT_DIR / "university_search_plan.py"
|
||||
|
||||
|
||||
def load_module(module_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
scholarship_filter = load_module("scholarship_filter", FILTER_PATH)
|
||||
university_search_plan = load_module("university_search_plan", PLANNER_PATH)
|
||||
|
||||
|
||||
class DeadlineStatusTest(unittest.TestCase):
|
||||
def test_current_kst_date_uses_korea_calendar_day(self):
|
||||
now = datetime(2026, 4, 15, 16, 30, tzinfo=timezone.utc)
|
||||
|
||||
today = scholarship_filter.current_kst_date(now)
|
||||
|
||||
self.assertEqual(today, date(2026, 4, 16))
|
||||
|
||||
def test_resolve_today_falls_back_to_kst_when_missing_or_invalid(self):
|
||||
with mock.patch.object(scholarship_filter, "current_kst_date", return_value=date(2026, 4, 16)):
|
||||
self.assertEqual(scholarship_filter.resolve_today(None), date(2026, 4, 16))
|
||||
self.assertEqual(scholarship_filter.resolve_today("not-a-date"), date(2026, 4, 16))
|
||||
|
||||
def test_infer_deadline_status_overrides_stale_cached_status_with_dates(self):
|
||||
record = {
|
||||
"deadline": {
|
||||
"status": "open",
|
||||
"start": "2026-04-01",
|
||||
"end": "2026-04-14",
|
||||
}
|
||||
}
|
||||
|
||||
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
|
||||
|
||||
self.assertEqual(status, "closed")
|
||||
|
||||
def test_infer_deadline_status_returns_unknown_for_noncanonical_cached_value_without_dates(self):
|
||||
record = {"deadline": {"status": "d-3"}}
|
||||
|
||||
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
|
||||
|
||||
self.assertEqual(status, "unknown")
|
||||
|
||||
def test_infer_deadline_status_treats_end_date_equal_to_today_as_open(self):
|
||||
record = {"deadline": {"end": "2026-04-15"}}
|
||||
|
||||
status = scholarship_filter.infer_deadline_status(record, date(2026, 4, 15))
|
||||
|
||||
self.assertEqual(status, "open")
|
||||
|
||||
def test_report_does_not_crash_on_noncanonical_status_and_counts_unknown(self):
|
||||
payload = json.dumps([{"name": "x", "deadline": {"status": "d-3"}}], ensure_ascii=False)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(FILTER_PATH), "report", "--today", "2026-04-15"],
|
||||
input=payload,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
self.assertIn("- 기준일: 2026-04-15 (Asia/Seoul (KST))", result.stdout)
|
||||
self.assertIn("- 상태 미확인: 1", result.stdout)
|
||||
self.assertIn("## 상태 미확인", result.stdout)
|
||||
|
||||
|
||||
class AmountHandlingTest(unittest.TestCase):
|
||||
def test_extract_amount_value_uses_amount_fields_and_ignores_irrelevant_notes(self):
|
||||
from_text = scholarship_filter.extract_amount_value({"amount": {"text": "생활비 250만원 지급"}})
|
||||
ignored_notes = scholarship_filter.extract_amount_value(
|
||||
{
|
||||
"amount": {"text": "등록금 전액"},
|
||||
"notes": ["작년에는 500만원 특별지원"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(from_text, 2500000)
|
||||
self.assertIsNone(ignored_notes)
|
||||
|
||||
def test_match_filter_keeps_text_only_amount_by_default_and_marks_it_unknown(self):
|
||||
args = Namespace(
|
||||
q=None,
|
||||
org_type=None,
|
||||
school_kind=None,
|
||||
school_name=None,
|
||||
student_level=None,
|
||||
major=None,
|
||||
department_name=None,
|
||||
grade_year=None,
|
||||
gpa=None,
|
||||
income_band=None,
|
||||
min_amount=2000000,
|
||||
max_amount=None,
|
||||
strict_amount=False,
|
||||
deadline_status=None,
|
||||
today="2026-04-15",
|
||||
only_open_now=False,
|
||||
upcoming_within_days=None,
|
||||
deadline_within_days=None,
|
||||
)
|
||||
|
||||
matched, reasons = scholarship_filter.match_filter({"amount": {"text": "등록금 전액"}}, args)
|
||||
|
||||
self.assertTrue(matched)
|
||||
self.assertIn("amount>=2000000?", reasons)
|
||||
|
||||
def test_match_filter_can_drop_text_only_amount_in_strict_mode(self):
|
||||
args = Namespace(
|
||||
q=None,
|
||||
org_type=None,
|
||||
school_kind=None,
|
||||
school_name=None,
|
||||
student_level=None,
|
||||
major=None,
|
||||
department_name=None,
|
||||
grade_year=None,
|
||||
gpa=None,
|
||||
income_band=None,
|
||||
min_amount=2000000,
|
||||
max_amount=None,
|
||||
strict_amount=True,
|
||||
deadline_status=None,
|
||||
today="2026-04-15",
|
||||
only_open_now=False,
|
||||
upcoming_within_days=None,
|
||||
deadline_within_days=None,
|
||||
)
|
||||
|
||||
matched, reasons = scholarship_filter.match_filter({"amount": {"text": "등록금 전액"}}, args)
|
||||
|
||||
self.assertFalse(matched)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
|
||||
class SparseFieldPolicyTest(unittest.TestCase):
|
||||
def test_eligibility_returns_indeterminate_when_profile_fields_are_missing(self):
|
||||
args = Namespace(
|
||||
org_type=None,
|
||||
school_kind="university",
|
||||
school_name="서울대학교",
|
||||
student_level="undergraduate",
|
||||
major=None,
|
||||
department_name=None,
|
||||
grade_year=None,
|
||||
gpa=None,
|
||||
income_band=5,
|
||||
)
|
||||
|
||||
result = scholarship_filter.eligibility_result({"name": "테스트 장학금"}, args)
|
||||
|
||||
self.assertEqual(result["status"], "indeterminate")
|
||||
self.assertEqual(result["failed"], [])
|
||||
self.assertEqual(
|
||||
result["unknown"],
|
||||
["school_kind=?", "school_name=?", "student_level=?", "income_band=?"],
|
||||
)
|
||||
|
||||
def test_school_name_filter_does_not_match_urls_any_more(self):
|
||||
args = Namespace(
|
||||
q=None,
|
||||
org_type=None,
|
||||
school_kind=None,
|
||||
school_name="SNU",
|
||||
student_level=None,
|
||||
major=None,
|
||||
department_name=None,
|
||||
grade_year=None,
|
||||
gpa=None,
|
||||
income_band=None,
|
||||
min_amount=None,
|
||||
max_amount=None,
|
||||
strict_amount=False,
|
||||
deadline_status=None,
|
||||
today="2026-04-15",
|
||||
only_open_now=False,
|
||||
upcoming_within_days=None,
|
||||
deadline_within_days=None,
|
||||
)
|
||||
record = {
|
||||
"organization": {"name": "한국장학재단"},
|
||||
"source_url": "https://www.kosaf.go.kr/snu-notice",
|
||||
}
|
||||
|
||||
matched, reasons = scholarship_filter.match_filter(record, args)
|
||||
|
||||
self.assertFalse(matched)
|
||||
self.assertEqual(reasons, [])
|
||||
|
||||
|
||||
class UniversitySearchPlanTest(unittest.TestCase):
|
||||
def test_school_domain_suppresses_broad_ac_kr_fallback_queries(self):
|
||||
payload = university_search_plan.build_school_queries(
|
||||
school_name="서울대학교",
|
||||
school_domain="snu.ac.kr",
|
||||
departments=["컴퓨터공학부"],
|
||||
colleges=[],
|
||||
year=2026,
|
||||
)
|
||||
|
||||
queries = payload["search_queries"]
|
||||
self.assertTrue(any(query.startswith("site:snu.ac.kr ") for query in queries))
|
||||
self.assertFalse(any('site:*.ac.kr "서울대학교"' in query for query in queries))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
148
korean-scholarship-search/scripts/university_search_plan.py
Normal file
148
korean-scholarship-search/scripts/university_search_plan.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generate exhaustive scholarship search queries for any Korean university."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
|
||||
def build_school_queries(
|
||||
school_name: str,
|
||||
school_domain: str | None,
|
||||
departments: list[str],
|
||||
colleges: list[str],
|
||||
year: int,
|
||||
) -> dict[str, object]:
|
||||
if school_domain:
|
||||
domain_targets = [f"site:{school_domain}"]
|
||||
else:
|
||||
domain_targets = [f"site:*.ac.kr \"{school_name}\""]
|
||||
|
||||
base_suffixes = [
|
||||
f"{year} 장학 공고",
|
||||
f"{year} 교내 장학",
|
||||
f"{year} 외부 장학",
|
||||
f"{year} 학생지원처 장학",
|
||||
f"{year} 학사공지 장학",
|
||||
"장학 공고",
|
||||
"교내 장학",
|
||||
"외부 장학 추천",
|
||||
"학생지원처 장학",
|
||||
"학사공지 장학",
|
||||
]
|
||||
|
||||
queries: list[str] = []
|
||||
for target in domain_targets:
|
||||
for suffix in base_suffixes:
|
||||
queries.append(f"{target} {suffix}")
|
||||
|
||||
for college in colleges:
|
||||
queries.append(f"{target} \"{college}\" 장학")
|
||||
queries.append(f"{target} \"{college}\" 외부 장학")
|
||||
queries.append(f"{target} \"{college}\" 장학생 모집")
|
||||
|
||||
for department in departments:
|
||||
queries.append(f"{target} \"{department}\" 장학")
|
||||
queries.append(f"{target} \"{department}\" 외부 장학")
|
||||
queries.append(f"{target} \"{department}\" 공지 장학생")
|
||||
queries.append(f"{target} \"{department}\" 대학원 장학")
|
||||
|
||||
url_hints = [
|
||||
"/scholarship",
|
||||
"/student/scholarship",
|
||||
"/notice",
|
||||
"/bbs",
|
||||
"/board",
|
||||
"/undergraduate/notice",
|
||||
"/graduate/notice",
|
||||
"/academics/undergraduate/scholarship",
|
||||
"/academics/graduate/scholarship",
|
||||
]
|
||||
|
||||
checklist = [
|
||||
"학교 대표 장학공지",
|
||||
"학생지원처 / 장학팀",
|
||||
"학사공지 / 일반공지",
|
||||
"단과대 공지",
|
||||
"학과 / 전공 공지",
|
||||
"대학원 공지",
|
||||
"첨부 PDF/HWP",
|
||||
]
|
||||
|
||||
return {
|
||||
"scope": "school",
|
||||
"school_name": school_name,
|
||||
"school_domain": school_domain,
|
||||
"year": year,
|
||||
"departments": departments,
|
||||
"colleges": colleges,
|
||||
"coverage_checklist": checklist,
|
||||
"search_queries": queries,
|
||||
"url_hints": url_hints,
|
||||
}
|
||||
|
||||
|
||||
def build_nationwide_queries(year: int) -> dict[str, object]:
|
||||
queries = [
|
||||
f"site:*.ac.kr {year} 장학 공고",
|
||||
f"site:*.ac.kr {year} 교내 장학",
|
||||
f"site:*.ac.kr {year} 외부 장학 추천",
|
||||
f"site:*.ac.kr {year} 학과 장학",
|
||||
f"site:*.ac.kr {year} 대학원 장학",
|
||||
"site:*.ac.kr 장학 공고",
|
||||
"site:*.ac.kr 교내 장학",
|
||||
"site:*.ac.kr 외부 장학 추천",
|
||||
"site:*.ac.kr 학과 장학",
|
||||
"site:*.ac.kr 대학원 장학",
|
||||
]
|
||||
return {
|
||||
"scope": "nationwide-universities",
|
||||
"year": year,
|
||||
"search_queries": queries,
|
||||
"coverage_notes": [
|
||||
"공개된 *.ac.kr 장학 공지 중심",
|
||||
"학교 본부 -> 단과대 -> 학과 순서로 수집",
|
||||
"검색엔진에 노출되지 않은 게시판은 누락 가능",
|
||||
"첨부 PDF/HWP 확인 필요",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate exhaustive official scholarship search queries for a Korean university or for nationwide university coverage.",
|
||||
)
|
||||
parser.add_argument("--school-name", help="University name, e.g. 서울대학교.")
|
||||
parser.add_argument("--school-domain", help="Official university domain, e.g. snu.ac.kr.")
|
||||
parser.add_argument("--department", action="append", default=[], help="Department or program name. Repeatable.")
|
||||
parser.add_argument("--college", action="append", default=[], help="College/faculty name. Repeatable.")
|
||||
parser.add_argument("--nationwide", action="store_true", help="Generate search queries for all Korean universities.")
|
||||
parser.add_argument("--year", type=int, default=date.today().year, help="Target year for notice search.")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.nationwide:
|
||||
payload = build_nationwide_queries(args.year)
|
||||
else:
|
||||
if not args.school_name:
|
||||
raise SystemExit("--school-name is required unless --nationwide is used")
|
||||
payload = build_school_queries(
|
||||
school_name=args.school_name,
|
||||
school_domain=args.school_domain,
|
||||
departments=args.department,
|
||||
colleges=args.college,
|
||||
year=args.year,
|
||||
)
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -20,7 +20,7 @@ upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabs
|
|||
|
||||
- "삼성전자 종목코드랑 시장구분 찾아줘"
|
||||
- "005930 기본정보 보여줘"
|
||||
- "SK하이닉스 20260404 종가/거래량 알려줘"
|
||||
- "SK하이닉스 20260408 종가/거래량 알려줘"
|
||||
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
|
||||
|
||||
## When not to use
|
||||
|
|
@ -75,7 +75,7 @@ GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&
|
|||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
종목 기본정보:
|
||||
|
|
@ -84,7 +84,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
종목 일별 시세:
|
||||
|
|
@ -93,7 +93,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/base-info'
|
|||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info' \
|
||||
--data-urlencode 'market=KOSPI' \
|
||||
--data-urlencode 'code=005930' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
## Response shape
|
||||
|
|
@ -113,7 +113,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
"listed_at": "1975-06-11"
|
||||
}
|
||||
],
|
||||
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
|
||||
"query": { "q": "삼성전자", "bas_dd": "20260408", "limit": 10 },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
|
@ -135,7 +135,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
"par_value": 100,
|
||||
"listed_shares": 5969782550
|
||||
},
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
|
@ -148,7 +148,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
"market": "KOSPI",
|
||||
"code": "005930",
|
||||
"standard_code": "KR7005930003",
|
||||
"base_date": "20260404",
|
||||
"base_date": "20260408",
|
||||
"name": "삼성전자",
|
||||
"close_price": 84000,
|
||||
"change_price": 1000,
|
||||
|
|
@ -160,7 +160,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
"trading_value": 1030000000000,
|
||||
"market_cap": 500000000000000
|
||||
},
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
|
||||
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260408" },
|
||||
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
|
||||
}
|
||||
```
|
||||
|
|
@ -168,8 +168,9 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
## Response policy
|
||||
|
||||
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
|
||||
- 일부 시장 upstream 이 실패하면 `upstream.degraded=true` 와 `failed_markets` 를 보고 부분 장애 여부를 함께 설명한다.
|
||||
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
|
||||
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
|
||||
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다. 이 경우 `trade-info` 는 502 대신 `not_found` 로 끝날 수 있다.
|
||||
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
|
||||
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
|
||||
|
||||
|
|
@ -185,7 +186,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/trade-info'
|
|||
|
||||
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
|
||||
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
|
||||
- upstream KRX 응답 오류면 502 응답
|
||||
- 검색 중 일부 시장 upstream 이 실패하면 200 응답이지만 `upstream.degraded=true` 와 `failed_markets` 를 함께 반환할 수 있다.
|
||||
- 모든 요청 시장에서 upstream KRX 조회가 실패하면 502 응답
|
||||
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
|
||||
|
||||
## Done when
|
||||
|
|
|
|||
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -1007,6 +1007,10 @@
|
|||
"resolved": "packages/kakao-bar-nearby",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/kbl-results": {
|
||||
"resolved": "packages/kbl-results",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/kleague-results": {
|
||||
"resolved": "packages/kleague-results",
|
||||
"link": true
|
||||
|
|
@ -1287,6 +1291,10 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/public-restroom-nearby": {
|
||||
"resolved": "packages/public-restroom-nearby",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"dev": true,
|
||||
|
|
@ -1687,7 +1695,7 @@
|
|||
}
|
||||
},
|
||||
"packages/blue-ribbon-nearby": {
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1697,7 +1705,7 @@
|
|||
}
|
||||
},
|
||||
"packages/cheap-gas-nearby": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1711,7 +1719,7 @@
|
|||
}
|
||||
},
|
||||
"packages/hipass-receipt": {
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.52.0"
|
||||
|
|
@ -1747,6 +1755,13 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/kbl-results": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/kleague-results": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
|
|
@ -1755,13 +1770,20 @@
|
|||
}
|
||||
},
|
||||
"packages/lck-analytics": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/market-kurly-search": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/public-restroom-nearby": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1776,7 +1798,7 @@
|
|||
}
|
||||
},
|
||||
"packages/used-car-price-search": {
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@
|
|||
],
|
||||
"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 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 && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search && 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 kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_kakaotalk_mac && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
"release:npm": "changeset publish"
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/mfds/food-safety/search' \
|
|||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
|
||||
--data-urlencode 'q=삼성전자' \
|
||||
--data-urlencode 'bas_dd=20260404'
|
||||
--data-urlencode 'bas_dd=20260408'
|
||||
```
|
||||
|
||||
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
|
||||
|
|
|
|||
|
|
@ -165,6 +165,10 @@ async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [
|
|||
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
|
||||
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
|
||||
|
||||
if (tradeItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
|
||||
if (directlyMatched.length > 0) {
|
||||
return directlyMatched.map((item) => normalizeTradeItem(item, market));
|
||||
|
|
@ -221,6 +225,14 @@ function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
|
|||
return `krx-base-info:${market}:${basDd}`;
|
||||
}
|
||||
|
||||
function serializeKrxError(error) {
|
||||
return {
|
||||
code: error?.code || "proxy_error",
|
||||
status_code: error?.statusCode || 502,
|
||||
message: error?.message || "Unknown KRX upstream error."
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBaseInfoSnapshot({
|
||||
market,
|
||||
basDd,
|
||||
|
|
@ -272,13 +284,20 @@ async function searchStocks({
|
|||
const successfulResults = settledResults
|
||||
.filter((result) => result.status === "fulfilled")
|
||||
.map((result) => result.value);
|
||||
const failedResults = settledResults
|
||||
.map((result, index) => ({ result, market: markets[index] }))
|
||||
.filter(({ result }) => result.status === "rejected")
|
||||
.map(({ result, market }) => ({
|
||||
market,
|
||||
...serializeKrxError(result.reason)
|
||||
}));
|
||||
|
||||
if (successfulResults.length === 0) {
|
||||
const firstFailure = settledResults.find((result) => result.status === "rejected");
|
||||
throw firstFailure?.reason || new Error("KRX search failed for every market.");
|
||||
}
|
||||
|
||||
return {
|
||||
const payload = {
|
||||
items: successfulResults
|
||||
.flatMap(({ market: entryMarket, items }) =>
|
||||
items
|
||||
|
|
@ -289,6 +308,17 @@ async function searchStocks({
|
|||
.slice(0, limit)
|
||||
.map(({ score, ...item }) => item)
|
||||
};
|
||||
|
||||
if (failedResults.length > 0) {
|
||||
payload.upstream = {
|
||||
degraded: true,
|
||||
requested_markets: markets,
|
||||
successful_markets: successfulResults.map(({ market: entryMarket }) => entryMarket),
|
||||
failed_markets: failedResults
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
|
|
|||
|
|
@ -2020,9 +2020,9 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
};
|
||||
}
|
||||
|
||||
let items;
|
||||
let result;
|
||||
try {
|
||||
const result = await searchStocks({
|
||||
result = await searchStocks({
|
||||
query: normalized.q,
|
||||
basDd: normalized.basDd,
|
||||
market: normalized.market,
|
||||
|
|
@ -2031,7 +2031,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
cache,
|
||||
cacheTtlMs: config.cacheTtlMs
|
||||
});
|
||||
items = result.items;
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
|
|
@ -2041,7 +2040,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
}
|
||||
|
||||
const payload = {
|
||||
items,
|
||||
items: result.items,
|
||||
query: {
|
||||
q: normalized.q,
|
||||
bas_dd: normalized.basDd,
|
||||
|
|
@ -2058,7 +2057,13 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
if (result.upstream) {
|
||||
payload.upstream = result.upstream;
|
||||
}
|
||||
|
||||
if (!result.upstream?.degraded) {
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
|
|
@ -2442,7 +2447,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
reply.code(404);
|
||||
return {
|
||||
error: "not_found",
|
||||
message: `기준일 ${normalized.basDd} 에 ${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
|
||||
message: `기준일 ${normalized.basDd} 에 ${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다. 휴장일이거나 데이터가 아직 없을 수 있습니다.`
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ test("korean stock search rate limit does not trust spoofed cf-connecting-ip on
|
|||
assert.equal(second.json().error, "rate_limited");
|
||||
});
|
||||
|
||||
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
|
||||
test("korean stock search surfaces degraded upstream metadata when another market fails", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
|
|
@ -249,10 +249,124 @@ test("korean stock search returns healthy market results when another market ups
|
|||
assert.equal(response.json().items[0].market, "KOSPI");
|
||||
assert.equal(response.json().items[0].code, "005930");
|
||||
assert.equal(response.json().items[0].name, "삼성전자");
|
||||
assert.equal(response.json().upstream.degraded, true);
|
||||
assert.deepEqual(response.json().upstream.requested_markets, ["KOSPI", "KOSDAQ", "KONEX"]);
|
||||
assert.deepEqual(response.json().upstream.successful_markets, ["KOSPI", "KONEX"]);
|
||||
assert.deepEqual(response.json().upstream.failed_markets, [
|
||||
{
|
||||
market: "KOSDAQ",
|
||||
code: "upstream_error",
|
||||
status_code: 502,
|
||||
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
|
||||
}
|
||||
]);
|
||||
assert.equal(fetchCalls.length, 3);
|
||||
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
|
||||
});
|
||||
|
||||
test("korean stock search does not cache degraded responses and retries a recovered market", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
let kosdaqAttempts = 0;
|
||||
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push({ url: text, headers: options.headers });
|
||||
|
||||
if (text.includes("stk_isu_base_info") || text.includes("knx_isu_base_info")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("ksq_isu_base_info")) {
|
||||
kosdaqAttempts += 1;
|
||||
|
||||
if (kosdaqAttempts === 1) {
|
||||
return new Response("boom", {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error"
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: [
|
||||
{
|
||||
ISU_CD: "KR7196170005",
|
||||
ISU_SRT_CD: "196170",
|
||||
ISU_NM: "알테오젠",
|
||||
ISU_ABBRV: "알테오젠",
|
||||
ISU_ENG_NM: "Alteogen",
|
||||
LIST_DD: "20140509",
|
||||
MKT_TP_NM: "KOSDAQ",
|
||||
SECUGRP_NM: "주권",
|
||||
SECT_TP_NM: "제약",
|
||||
KIND_STKCERT_TP_NM: "보통주",
|
||||
PARVAL: "500",
|
||||
LIST_SHRS: "53470829"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-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/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/search?q=%EC%95%8C%ED%85%8C%EC%98%A4%EC%A0%A0&bas_dd=20260408"
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.json().items.length, 0);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(first.json().upstream.degraded, true);
|
||||
assert.deepEqual(first.json().upstream.failed_markets, [
|
||||
{
|
||||
market: "KOSDAQ",
|
||||
code: "upstream_error",
|
||||
status_code: 502,
|
||||
message: "KRX API HTTP 오류 (status: 500): Internal Server Error"
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(second.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().items.length, 1);
|
||||
assert.equal(second.json().items[0].market, "KOSDAQ");
|
||||
assert.equal(second.json().items[0].code, "196170");
|
||||
assert.equal(kosdaqAttempts, 2);
|
||||
assert.equal(fetchCalls.length, 4);
|
||||
});
|
||||
|
||||
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
|
|
@ -538,6 +652,56 @@ test("korean stock trade-info endpoint does not relabel an unmatched single-row
|
|||
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
|
||||
});
|
||||
|
||||
test("korean stock trade-info endpoint treats empty trade snapshots as not_found without base-info fallback", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
const text = String(url);
|
||||
fetchCalls.push(text);
|
||||
|
||||
if (text.includes("stk_bydd_trd")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
OutBlock_1: []
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (text.includes("stk_isu_base_info")) {
|
||||
throw new Error("base-info fallback should not run for empty trade snapshots");
|
||||
}
|
||||
|
||||
throw new Error(`unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
KRX_API_KEY: "krx-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-stock/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 404);
|
||||
assert.equal(response.json().error, "not_found");
|
||||
assert.match(response.json().message, /휴장일이거나 데이터가 아직 없을 수 있습니다/);
|
||||
assert.deepEqual(fetchCalls, [
|
||||
"https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd?basDd=20260404"
|
||||
]);
|
||||
});
|
||||
|
||||
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
let providerCalls = 0;
|
||||
const app = buildServer({
|
||||
|
|
|
|||
5
packages/kbl-results/CHANGELOG.md
Normal file
5
packages/kbl-results/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# kbl-results
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial release.
|
||||
59
packages/kbl-results/README.md
Normal file
59
packages/kbl-results/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# kbl-results
|
||||
|
||||
공식 KBL JSON 엔드포인트를 감싼 재사용 가능한 Node.js 클라이언트입니다. 날짜별 경기 결과와 현재 순위를 함께 조회할 수 있습니다.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install kbl-results
|
||||
```
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 일정/결과: `https://api.kbl.or.kr/match/list`
|
||||
- 팀 순위: `https://api.kbl.or.kr/league/rank/team`
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
const { getKBLSummary, getMatchResults, getStandings } = require("kbl-results");
|
||||
|
||||
(async () => {
|
||||
const results = await getMatchResults("2026-04-01", {
|
||||
team: "서울 SK",
|
||||
});
|
||||
|
||||
const standings = await getStandings();
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "부산 KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
console.log(results.matches[0]);
|
||||
console.log(standings.rows[0]);
|
||||
console.log(summary);
|
||||
})();
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `getMatchResults(date, options)`
|
||||
|
||||
- `date`: `YYYY-MM-DD` 또는 `Date`
|
||||
- `options.team`: short name / full name / team code alias
|
||||
- `options.seasonGrade`: 기본값은 `1` (KBL 1군)
|
||||
|
||||
### `getStandings()`
|
||||
|
||||
- 현재 KBL 팀 순위를 반환합니다.
|
||||
|
||||
### `getKBLSummary(date, options)`
|
||||
|
||||
- 날짜 결과와 현재 순위를 한 번에 반환합니다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 공식 KBL JSON 엔드포인트 기준이라 HTML 크롤링보다 유지보수가 단순합니다.
|
||||
- `match/list` 는 `fromDate` / `toDate` 를 `YYYYMMDD` 형식으로 받습니다.
|
||||
- 1군 KBL 조회 기본값은 `seasonGrade=1` 입니다.
|
||||
31
packages/kbl-results/package.json
Normal file
31
packages/kbl-results/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "kbl-results",
|
||||
"version": "0.1.0",
|
||||
"description": "Official KBL match results and standings client",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
||||
},
|
||||
"keywords": [
|
||||
"k-skill",
|
||||
"kbl",
|
||||
"basketball",
|
||||
"korea"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
123
packages/kbl-results/src/index.js
Normal file
123
packages/kbl-results/src/index.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
const {
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
} = require("./parse");
|
||||
|
||||
const MATCH_LIST_URL = "https://api.kbl.or.kr/match/list";
|
||||
const TEAM_RANK_URL = "https://api.kbl.or.kr/league/rank/team";
|
||||
const DEFAULT_HEADERS = {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"user-agent": "k-skill/kbl-results",
|
||||
"x-requested-with": "XMLHttpRequest",
|
||||
channel: "WEB",
|
||||
teamcode: "XX",
|
||||
lang: "ko",
|
||||
};
|
||||
|
||||
async function requestJson(url, options = {}) {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...DEFAULT_HEADERS,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`KBL request failed with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchMatchList({ date, seasonGrade = 1, tcodeList = "all", fetchImpl, signal }) {
|
||||
const queryDate = normalizeDateInput(date);
|
||||
const url = new URL(MATCH_LIST_URL);
|
||||
url.searchParams.set("fromDate", queryDate.compactDate);
|
||||
url.searchParams.set("toDate", queryDate.compactDate);
|
||||
url.searchParams.set("tcodeList", tcodeList);
|
||||
if (seasonGrade != null) {
|
||||
url.searchParams.set("seasonGrade", String(seasonGrade));
|
||||
}
|
||||
|
||||
return requestJson(url.toString(), {
|
||||
fetchImpl,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchStandings({ fetchImpl, signal }) {
|
||||
return requestJson(TEAM_RANK_URL, {
|
||||
fetchImpl,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function getMatchResults(date, options = {}) {
|
||||
const payload = options.schedulePayload || await fetchMatchList({
|
||||
date,
|
||||
seasonGrade: options.seasonGrade == null ? 1 : options.seasonGrade,
|
||||
tcodeList: options.tcodeList || "all",
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return normalizeScheduleResponse(payload, {
|
||||
date,
|
||||
team: options.team,
|
||||
seasonGrade: options.seasonGrade == null ? 1 : options.seasonGrade,
|
||||
standingsRows: options.standingsRows,
|
||||
});
|
||||
}
|
||||
|
||||
async function getStandings(options = {}) {
|
||||
const payload = options.standingsPayload || await fetchStandings({
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
return normalizeStandingsResponse(payload);
|
||||
}
|
||||
|
||||
async function getKBLSummary(date, options = {}) {
|
||||
const standingsPayload = options.includeStandings === false
|
||||
? null
|
||||
: (options.standingsPayload || await fetchStandings({
|
||||
fetchImpl: options.fetchImpl,
|
||||
signal: options.signal,
|
||||
}));
|
||||
|
||||
const matches = options.matchesResponse || await getMatchResults(date, {
|
||||
...options,
|
||||
standingsRows: standingsPayload || undefined,
|
||||
});
|
||||
|
||||
const summary = {
|
||||
queryDate: matches.queryDate,
|
||||
filteredTeam: matches.filteredTeam,
|
||||
matches: matches.matches,
|
||||
};
|
||||
|
||||
if (options.includeStandings !== false) {
|
||||
summary.standings = normalizeStandingsResponse(standingsPayload);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchMatchList,
|
||||
fetchStandings,
|
||||
getKBLSummary,
|
||||
getMatchResults,
|
||||
getStandings,
|
||||
};
|
||||
436
packages/kbl-results/src/parse.js
Normal file
436
packages/kbl-results/src/parse.js
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
const KNOWN_TEAMS = [
|
||||
{ code: "50", name: "창원 LG", fullName: "창원 LG 세이커스", logoClass: "lg", aliases: ["LG", "세이커스"] },
|
||||
{ code: "70", name: "안양 정관장", fullName: "안양 정관장 레드부스터스", logoClass: "kgc", aliases: ["정관장", "KGC", "레드부스터스"] },
|
||||
{ code: "16", name: "원주 DB", fullName: "원주 DB 프로미", logoClass: "db", aliases: ["DB", "프로미"] },
|
||||
{ code: "55", name: "서울 SK", fullName: "서울 SK 나이츠", logoClass: "sk", aliases: ["SK", "나이츠"] },
|
||||
{ code: "66", name: "고양 소노", fullName: "고양 소노 스카이거너스", logoClass: "sono", aliases: ["소노", "스카이거너스", "SONO"] },
|
||||
{ code: "60", name: "부산 KCC", fullName: "부산 KCC 이지스", logoClass: "kcc", aliases: ["KCC", "이지스"] },
|
||||
{ code: "06", name: "수원 KT", fullName: "수원 KT 소닉붐", logoClass: "kt", aliases: ["KT", "소닉붐"] },
|
||||
{ code: "10", name: "울산 현대모비스", fullName: "울산 현대모비스 피버스", logoClass: "hd", aliases: ["현대모비스", "모비스", "피버스"] },
|
||||
{ code: "64", name: "대구 한국가스공사", fullName: "대구 한국가스공사 페가수스", logoClass: "pega", aliases: ["한국가스공사", "가스공사", "페가수스"] },
|
||||
{ code: "35", name: "서울 삼성", fullName: "서울 삼성 썬더스", logoClass: "ss", aliases: ["삼성", "썬더스"] },
|
||||
];
|
||||
|
||||
const STATUS_MAP = {
|
||||
live: { code: "LIVE", state: "live", label: "진행 중" },
|
||||
finished: { code: "ENDED", state: "finished", label: "종료" },
|
||||
scheduled: { code: "SCHEDULED", state: "scheduled", label: "예정" },
|
||||
};
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const parts = formatter.formatToParts(value).reduce((acc, part) => {
|
||||
if (part.type !== "literal") {
|
||||
acc[part.type] = part.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return buildDateParts(parts.year, parts.month, parts.day);
|
||||
}
|
||||
|
||||
const match = String(value || "").trim().match(/^(\d{4})[-.](\d{2})[-.](\d{2})$/);
|
||||
if (!match || !isValidCalendarDate(match[1], match[2], match[3])) {
|
||||
throw new Error("date must be a valid Date or YYYY-MM-DD string.");
|
||||
}
|
||||
|
||||
return buildDateParts(match[1], match[2], match[3]);
|
||||
}
|
||||
|
||||
function normalizeScheduleResponse(payload, options = {}) {
|
||||
const queryDate = options.date ? normalizeDateInput(options.date) : null;
|
||||
const seasonGrade = options.seasonGrade == null ? 1 : Number(options.seasonGrade);
|
||||
const teamDirectory = buildTeamDirectory({
|
||||
scheduleRows: payload,
|
||||
standingsRows: options.standingsRows,
|
||||
});
|
||||
const requestedTeam = options.team ? resolveTeamQuery(options.team, teamDirectory) : null;
|
||||
const rows = Array.isArray(payload) ? payload : [];
|
||||
|
||||
const matches = rows
|
||||
.filter((item) => Number(item.seasonGrade || 1) === seasonGrade)
|
||||
.filter((item) => !queryDate || item.gameDate === queryDate.compactDate)
|
||||
.filter((item) => !requestedTeam || itemMatchesRequestedTeam(item, requestedTeam))
|
||||
.map((item) => normalizeScheduleItem(item, teamDirectory))
|
||||
.sort(compareMatches);
|
||||
|
||||
return {
|
||||
queryDate: queryDate?.isoDate ?? null,
|
||||
seasonGrade,
|
||||
filteredTeam: requestedTeam
|
||||
? {
|
||||
input: requestedTeam.input,
|
||||
normalized: requestedTeam.fullName,
|
||||
code: requestedTeam.code,
|
||||
}
|
||||
: null,
|
||||
matches,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStandingsResponse(payload) {
|
||||
const teamDirectory = buildTeamDirectory({ standingsRows: payload });
|
||||
const rows = (Array.isArray(payload) ? payload : [])
|
||||
.map((item) => {
|
||||
const team = stripAliasTokens(getTeam(item.tcode, item.tname, item.tnameF, item.teamLogoClass, teamDirectory));
|
||||
const win = normalizeNumber(item.win) ?? 0;
|
||||
const loss = normalizeNumber(item.loss) ?? 0;
|
||||
const draw = normalizeNumber(item.draw) ?? 0;
|
||||
|
||||
return {
|
||||
rank: normalizeNumber(item.rank),
|
||||
team,
|
||||
win,
|
||||
loss,
|
||||
draw,
|
||||
gamesBehind: normalizeNumber(item.winDiff) ?? 0,
|
||||
winningPercentage: calculateWinningPercentage(win, loss, draw),
|
||||
home: {
|
||||
win: normalizeNumber(item.hwin) ?? 0,
|
||||
loss: normalizeNumber(item.hloss) ?? 0,
|
||||
},
|
||||
away: {
|
||||
win: normalizeNumber(item.awin) ?? 0,
|
||||
loss: normalizeNumber(item.aloss) ?? 0,
|
||||
},
|
||||
streak: {
|
||||
win: normalizeNumber(item.contiWin) ?? 0,
|
||||
loss: normalizeNumber(item.contiLoss) ?? 0,
|
||||
},
|
||||
maxStreak: {
|
||||
win: normalizeNumber(item.maxWin) ?? 0,
|
||||
loss: normalizeNumber(item.maxLoss) ?? 0,
|
||||
},
|
||||
lastFive: normalizeLastRecord(item.lastRecord),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.rank - right.rank);
|
||||
|
||||
return {
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScheduleItem(item, teamDirectory) {
|
||||
const homeTeam = stripAliasTokens(getTeam(item.tcodeH, item.tnameH, item.tnameFH, item.logoH, teamDirectory));
|
||||
const awayTeam = stripAliasTokens(getTeam(item.tcodeA, item.tnameA, item.tnameFA, item.logoA, teamDirectory));
|
||||
const status = normalizeMatchStatus(item);
|
||||
const score = {
|
||||
home: normalizeNumber(item.scoreH),
|
||||
away: normalizeNumber(item.scoreA),
|
||||
};
|
||||
|
||||
return {
|
||||
gameKey: item.gmkey || null,
|
||||
gameNumber: normalizeNumber(item.gameNo),
|
||||
gameCode: item.gameCode || null,
|
||||
seasonCode: normalizeNumber(item.seasonCode),
|
||||
seasonGrade: normalizeNumber(item.seasonGrade),
|
||||
competitionName: item.seasonName1 || null,
|
||||
seasonCategory: {
|
||||
code: item.seasonCategory || null,
|
||||
label: item.seasonCategoryName || null,
|
||||
},
|
||||
date: compactDateToIso(item.gameDate),
|
||||
dateLabel: item.gameDate || null,
|
||||
weekDay: item.weekDay || null,
|
||||
startTime: compactTimeToClock(item.gameStart),
|
||||
endTime: compactTimeToClock(item.gameEnd),
|
||||
status,
|
||||
homeTeam,
|
||||
awayTeam,
|
||||
score,
|
||||
winner: determineWinner(score, status, homeTeam, awayTeam),
|
||||
venue: {
|
||||
shortName: item.stadiumname || null,
|
||||
name: item.stadiumnameF || item.stadiumname || null,
|
||||
},
|
||||
broadcastChannels: splitBroadcastChannels(item.tv),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTeamDirectory({ scheduleRows = [], standingsRows = [] } = {}) {
|
||||
const directory = new Map();
|
||||
|
||||
for (const team of KNOWN_TEAMS) {
|
||||
upsertTeam(directory, team.code, team.name, team.fullName, team.logoClass, team.aliases);
|
||||
}
|
||||
|
||||
for (const item of scheduleRows || []) {
|
||||
upsertTeam(directory, item.tcodeH, item.tnameH, item.tnameFH, item.logoH);
|
||||
upsertTeam(directory, item.tcodeA, item.tnameA, item.tnameFA, item.logoA);
|
||||
}
|
||||
|
||||
for (const item of standingsRows || []) {
|
||||
upsertTeam(directory, item.tcode, item.tname, item.tnameF, item.teamLogoClass);
|
||||
}
|
||||
|
||||
return directory;
|
||||
}
|
||||
|
||||
function upsertTeam(directory, code, name, fullName, logoClass, aliases = []) {
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = directory.get(code) || {
|
||||
code,
|
||||
name: name || code,
|
||||
fullName: fullName || name || code,
|
||||
logoClass: logoClass || null,
|
||||
aliasTokens: new Set(),
|
||||
};
|
||||
|
||||
if (name) {
|
||||
existing.name = name;
|
||||
}
|
||||
if (fullName) {
|
||||
existing.fullName = fullName;
|
||||
}
|
||||
if (logoClass) {
|
||||
existing.logoClass = logoClass;
|
||||
}
|
||||
|
||||
const values = [
|
||||
code,
|
||||
name,
|
||||
fullName,
|
||||
logoClass,
|
||||
...aliases,
|
||||
removeCityPrefix(name),
|
||||
removeCityPrefix(fullName),
|
||||
extractEnglishFragment(name),
|
||||
extractEnglishFragment(fullName),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const value of values) {
|
||||
existing.aliasTokens.add(normalizeToken(value));
|
||||
}
|
||||
|
||||
directory.set(code, existing);
|
||||
}
|
||||
|
||||
function resolveTeamQuery(query, teamDirectory) {
|
||||
const input = String(query || "").trim();
|
||||
const token = normalizeToken(input);
|
||||
const exact = [];
|
||||
const fuzzy = [];
|
||||
|
||||
for (const team of teamDirectory.values()) {
|
||||
if (team.aliasTokens.has(token)) {
|
||||
exact.push(team);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const alias of team.aliasTokens) {
|
||||
if (alias.includes(token) || token.includes(alias)) {
|
||||
fuzzy.push(team);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matches = exact.length ? exact : fuzzy;
|
||||
if (matches.length === 1) {
|
||||
return {
|
||||
...matches[0],
|
||||
input,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: null,
|
||||
name: input,
|
||||
fullName: input,
|
||||
input,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
function itemMatchesRequestedTeam(item, requestedTeam) {
|
||||
if (requestedTeam.code) {
|
||||
return item.tcodeH === requestedTeam.code || item.tcodeA === requestedTeam.code;
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeToken(item.tnameH),
|
||||
normalizeToken(item.tnameFH),
|
||||
normalizeToken(item.tnameA),
|
||||
normalizeToken(item.tnameFA),
|
||||
].some((token) => token && (token.includes(requestedTeam.token) || requestedTeam.token.includes(token)));
|
||||
}
|
||||
|
||||
function normalizeMatchStatus(item) {
|
||||
if (Number(item.isEnded) === 1) {
|
||||
return {
|
||||
...STATUS_MAP.finished,
|
||||
finished: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (Number(item.isStarted) === 1) {
|
||||
return {
|
||||
...STATUS_MAP.live,
|
||||
finished: false,
|
||||
quarter: item.playingQuarter || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...STATUS_MAP.scheduled,
|
||||
finished: false,
|
||||
};
|
||||
}
|
||||
|
||||
function determineWinner(score, status, homeTeam, awayTeam) {
|
||||
if (!status.finished) {
|
||||
return null;
|
||||
}
|
||||
if (score.home === score.away) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
team: score.home > score.away ? homeTeam : awayTeam,
|
||||
};
|
||||
}
|
||||
|
||||
function getTeam(code, name, fullName, logoClass, teamDirectory) {
|
||||
const team = teamDirectory.get(String(code)) || {};
|
||||
return {
|
||||
code: String(code),
|
||||
name: name || team.name || String(code),
|
||||
fullName: fullName || team.fullName || name || String(code),
|
||||
logoClass: logoClass || team.logoClass || null,
|
||||
aliasTokens: team.aliasTokens || new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
function stripAliasTokens(team) {
|
||||
const { aliasTokens, ...rest } = team;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function normalizeToken(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toUpperCase()
|
||||
.replace(/[^0-9A-Z가-힣]+/g, "");
|
||||
}
|
||||
|
||||
function buildDateParts(year, month, day) {
|
||||
const paddedMonth = String(month).padStart(2, "0");
|
||||
const paddedDay = String(day).padStart(2, "0");
|
||||
return {
|
||||
isoDate: `${year}-${paddedMonth}-${paddedDay}`,
|
||||
compactDate: `${year}${paddedMonth}${paddedDay}`,
|
||||
year: String(year),
|
||||
month: paddedMonth,
|
||||
day: paddedDay,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidCalendarDate(year, month, day) {
|
||||
const numericYear = Number(year);
|
||||
const numericMonth = Number(month);
|
||||
const numericDay = Number(day);
|
||||
|
||||
if (!Number.isInteger(numericYear) || !Number.isInteger(numericMonth) || !Number.isInteger(numericDay)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (numericMonth < 1 || numericMonth > 12 || numericDay < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxDay = [31, isLeapYear(numericYear) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][numericMonth - 1];
|
||||
return numericDay <= maxDay;
|
||||
}
|
||||
|
||||
function isLeapYear(year) {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = Number(value);
|
||||
return Number.isFinite(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function compactDateToIso(value) {
|
||||
const input = String(value || "");
|
||||
if (!/^\d{8}$/.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${input.slice(0, 4)}-${input.slice(4, 6)}-${input.slice(6, 8)}`;
|
||||
}
|
||||
|
||||
function compactTimeToClock(value) {
|
||||
const input = String(value || "");
|
||||
if (!/^\d{4}$/.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${input.slice(0, 2)}:${input.slice(2, 4)}`;
|
||||
}
|
||||
|
||||
function splitBroadcastChannels(value) {
|
||||
return String(value || "")
|
||||
.split("/")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function compareMatches(left, right) {
|
||||
const leftSortKey = `${left.date || ""}${(left.startTime || "").replace(":", "")}${String(left.gameNumber || "").padStart(4, "0")}`;
|
||||
const rightSortKey = `${right.date || ""}${(right.startTime || "").replace(":", "")}${String(right.gameNumber || "").padStart(4, "0")}`;
|
||||
return leftSortKey.localeCompare(rightSortKey);
|
||||
}
|
||||
|
||||
function removeCityPrefix(value) {
|
||||
const parts = String(value || "").trim().split(/\s+/);
|
||||
return parts.length >= 2 ? parts.slice(1).join(" ") : value;
|
||||
}
|
||||
|
||||
function extractEnglishFragment(value) {
|
||||
const matches = String(value || "").match(/[A-Za-z]{2,}/g);
|
||||
return matches ? matches.join(" ") : null;
|
||||
}
|
||||
|
||||
function calculateWinningPercentage(win, loss, draw) {
|
||||
const total = win + loss + draw;
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number((win / total).toFixed(3));
|
||||
}
|
||||
|
||||
function normalizeLastRecord(value) {
|
||||
return Array.isArray(value)
|
||||
? value.slice(0, 5).map((entry) => (Number(entry) === 1 ? "W" : "L"))
|
||||
: [];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTeamDirectory,
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
normalizeToken,
|
||||
resolveTeamQuery,
|
||||
};
|
||||
1256
packages/kbl-results/test/fixtures/schedule-kbl-2026-04.json
vendored
Normal file
1256
packages/kbl-results/test/fixtures/schedule-kbl-2026-04.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
262
packages/kbl-results/test/fixtures/standings-kbl-2026.json
vendored
Normal file
262
packages/kbl-results/test/fixtures/standings-kbl-2026.json
vendored
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
[
|
||||
{
|
||||
"rank": 1,
|
||||
"tcode": "50",
|
||||
"win": 36,
|
||||
"loss": 18,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 2,
|
||||
"winDiff": 0.0,
|
||||
"maxWin": 4,
|
||||
"maxLoss": 2,
|
||||
"tname": "창원 LG",
|
||||
"tnameF": "창원 LG 세이커스",
|
||||
"teamLogoClass": "lg",
|
||||
"hwin": 18,
|
||||
"hloss": 9,
|
||||
"awin": 18,
|
||||
"aloss": 9,
|
||||
"lastRecord": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 2,
|
||||
"tcode": "70",
|
||||
"win": 35,
|
||||
"loss": 19,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 1.0,
|
||||
"maxWin": 5,
|
||||
"maxLoss": 2,
|
||||
"tname": "안양 정관장",
|
||||
"tnameF": "안양 정관장 레드부스터스",
|
||||
"teamLogoClass": "kgc",
|
||||
"hwin": 20,
|
||||
"hloss": 7,
|
||||
"awin": 15,
|
||||
"aloss": 12,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 3,
|
||||
"tcode": "16",
|
||||
"win": 33,
|
||||
"loss": 21,
|
||||
"draw": 0,
|
||||
"contiWin": 4,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 3.0,
|
||||
"maxWin": 7,
|
||||
"maxLoss": 3,
|
||||
"tname": "원주 DB",
|
||||
"tnameF": "원주 DB 프로미",
|
||||
"teamLogoClass": "db",
|
||||
"hwin": 17,
|
||||
"hloss": 10,
|
||||
"awin": 16,
|
||||
"aloss": 11,
|
||||
"lastRecord": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 4,
|
||||
"tcode": "55",
|
||||
"win": 32,
|
||||
"loss": 22,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 2,
|
||||
"winDiff": 4.0,
|
||||
"maxWin": 5,
|
||||
"maxLoss": 4,
|
||||
"tname": "서울 SK",
|
||||
"tnameF": "서울 SK 나이츠",
|
||||
"teamLogoClass": "sk",
|
||||
"hwin": 18,
|
||||
"hloss": 9,
|
||||
"awin": 14,
|
||||
"aloss": 13,
|
||||
"lastRecord": [
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 5,
|
||||
"tcode": "66",
|
||||
"win": 28,
|
||||
"loss": 26,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 8.0,
|
||||
"maxWin": 10,
|
||||
"maxLoss": 4,
|
||||
"tname": "고양 소노",
|
||||
"tnameF": "고양 소노 스카이거너스",
|
||||
"teamLogoClass": "sono",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 13,
|
||||
"aloss": 14,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 6,
|
||||
"tcode": "60",
|
||||
"win": 28,
|
||||
"loss": 26,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 8.0,
|
||||
"maxWin": 7,
|
||||
"maxLoss": 6,
|
||||
"tname": "부산 KCC",
|
||||
"tnameF": "부산 KCC 이지스",
|
||||
"teamLogoClass": "kcc",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 13,
|
||||
"aloss": 14,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 7,
|
||||
"tcode": "06",
|
||||
"win": 27,
|
||||
"loss": 27,
|
||||
"draw": 0,
|
||||
"contiWin": 2,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 9.0,
|
||||
"maxWin": 4,
|
||||
"maxLoss": 3,
|
||||
"tname": "수원 KT",
|
||||
"tnameF": "수원 KT 소닉붐",
|
||||
"teamLogoClass": "kt",
|
||||
"hwin": 15,
|
||||
"hloss": 12,
|
||||
"awin": 12,
|
||||
"aloss": 15,
|
||||
"lastRecord": [
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 8,
|
||||
"tcode": "10",
|
||||
"win": 18,
|
||||
"loss": 36,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 18.0,
|
||||
"maxWin": 3,
|
||||
"maxLoss": 7,
|
||||
"tname": "울산 현대모비스",
|
||||
"tnameF": "울산 현대모비스 피버스",
|
||||
"teamLogoClass": "hd",
|
||||
"hwin": 11,
|
||||
"hloss": 16,
|
||||
"awin": 7,
|
||||
"aloss": 20,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 9,
|
||||
"tcode": "64",
|
||||
"win": 17,
|
||||
"loss": 37,
|
||||
"draw": 0,
|
||||
"contiWin": 1,
|
||||
"contiLoss": 0,
|
||||
"winDiff": 19.0,
|
||||
"maxWin": 2,
|
||||
"maxLoss": 8,
|
||||
"tname": "대구 한국가스공사",
|
||||
"tnameF": "대구 한국가스공사 페가수스",
|
||||
"teamLogoClass": "pega",
|
||||
"hwin": 10,
|
||||
"hloss": 16,
|
||||
"awin": 6,
|
||||
"aloss": 21,
|
||||
"lastRecord": [
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"rank": 10,
|
||||
"tcode": "35",
|
||||
"win": 16,
|
||||
"loss": 38,
|
||||
"draw": 0,
|
||||
"contiWin": 0,
|
||||
"contiLoss": 1,
|
||||
"winDiff": 20.0,
|
||||
"maxWin": 3,
|
||||
"maxLoss": 8,
|
||||
"tname": "서울 삼성",
|
||||
"tnameF": "서울 삼성 썬더스",
|
||||
"teamLogoClass": "ss",
|
||||
"hwin": 8,
|
||||
"hloss": 19,
|
||||
"awin": 8,
|
||||
"aloss": 19,
|
||||
"lastRecord": [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
}
|
||||
]
|
||||
181
packages/kbl-results/test/index.test.js
Normal file
181
packages/kbl-results/test/index.test.js
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
getKBLSummary,
|
||||
getMatchResults,
|
||||
getStandings,
|
||||
} = require("../src/index");
|
||||
const {
|
||||
normalizeDateInput,
|
||||
normalizeScheduleResponse,
|
||||
normalizeStandingsResponse,
|
||||
} = require("../src/parse");
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const schedulePayload = JSON.parse(
|
||||
fs.readFileSync(path.join(fixturesDir, "schedule-kbl-2026-04.json"), "utf8"),
|
||||
);
|
||||
const standingsPayload = JSON.parse(
|
||||
fs.readFileSync(path.join(fixturesDir, "standings-kbl-2026.json"), "utf8"),
|
||||
);
|
||||
|
||||
test("normalizeDateInput accepts YYYY-MM-DD and Date inputs", () => {
|
||||
assert.equal(normalizeDateInput("2026-04-01").isoDate, "2026-04-01");
|
||||
assert.equal(
|
||||
normalizeDateInput(new Date("2026-04-01T03:00:00Z")).isoDate,
|
||||
"2026-04-01",
|
||||
);
|
||||
assert.throws(() => normalizeDateInput("2026-13-40"), /date must be a valid Date or YYYY-MM-DD string\./);
|
||||
});
|
||||
|
||||
test("normalizeScheduleResponse filters official KBL schedule data by date and team alias", () => {
|
||||
const result = normalizeScheduleResponse(schedulePayload, {
|
||||
date: "2026-04-01",
|
||||
team: "KCC",
|
||||
});
|
||||
|
||||
assert.equal(result.queryDate, "2026-04-01");
|
||||
assert.equal(result.matches.length, 1);
|
||||
assert.equal(result.matches[0].competitionName, "2025-2026");
|
||||
assert.equal(result.matches[0].seasonCategory.code, "R");
|
||||
assert.equal(result.matches[0].status.code, "ENDED");
|
||||
assert.equal(result.matches[0].status.label, "종료");
|
||||
assert.equal(result.matches[0].homeTeam.code, "60");
|
||||
assert.equal(result.matches[0].homeTeam.name, "부산 KCC");
|
||||
assert.equal(result.matches[0].homeTeam.fullName, "부산 KCC 이지스");
|
||||
assert.equal(result.matches[0].awayTeam.code, "55");
|
||||
assert.deepEqual(result.matches[0].score, { home: 81, away: 79 });
|
||||
assert.equal(result.matches[0].winner.team.code, "60");
|
||||
assert.equal(result.matches[0].venue.name, "부산사직체육관");
|
||||
assert.deepEqual(result.matches[0].broadcastChannels, ["tvN SPORTS"]);
|
||||
assert.equal(result.filteredTeam.code, "60");
|
||||
assert.equal(result.filteredTeam.normalized, "부산 KCC 이지스");
|
||||
});
|
||||
|
||||
test("normalizeStandingsResponse keeps the official KBL team table shape", () => {
|
||||
const standings = normalizeStandingsResponse(standingsPayload);
|
||||
const sk = standings.rows.find((row) => row.team.code === "55");
|
||||
|
||||
assert.equal(standings.rows.length, 10);
|
||||
assert.equal(standings.rows[0].team.code, "50");
|
||||
assert.equal(sk.rank, 4);
|
||||
assert.equal(sk.team.name, "서울 SK");
|
||||
assert.equal(sk.team.fullName, "서울 SK 나이츠");
|
||||
assert.equal(sk.win, 32);
|
||||
assert.equal(sk.loss, 22);
|
||||
assert.equal(sk.gamesBehind, 4);
|
||||
assert.deepEqual(sk.lastFive, ["L", "L", "W", "L", "L"]);
|
||||
});
|
||||
|
||||
test("public fetchers compose day results with current standings via mocked fetch", async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
|
||||
global.fetch = async (url, options = {}) => {
|
||||
const target = String(url);
|
||||
calls.push({
|
||||
target,
|
||||
method: options.method || "GET",
|
||||
headers: options.headers || {},
|
||||
});
|
||||
|
||||
if (target.includes("/match/list?")) {
|
||||
return makeResponse(schedulePayload);
|
||||
}
|
||||
|
||||
if (target.endsWith("/league/rank/team")) {
|
||||
return makeResponse(standingsPayload);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${target}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const matches = await getMatchResults("2026-04-01", { team: "서울 SK" });
|
||||
assert.equal(matches.matches.length, 1);
|
||||
assert.equal(matches.matches[0].awayTeam.fullName, "서울 SK 나이츠");
|
||||
|
||||
const standings = await getStandings();
|
||||
assert.equal(standings.rows[0].team.fullName, "창원 LG 세이커스");
|
||||
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "KCC",
|
||||
includeStandings: true,
|
||||
});
|
||||
|
||||
assert.equal(summary.matches.length, 1);
|
||||
assert.equal(summary.standings.rows[0].rank, 1);
|
||||
assert.equal(summary.standings.rows[0].team.fullName, "창원 LG 세이커스");
|
||||
assert.ok(
|
||||
calls.some((call) => call.target.includes("fromDate=20260401")),
|
||||
"expected official date params in the live schedule request",
|
||||
);
|
||||
assert.ok(
|
||||
calls.every((call) => call.headers["accept-language"]?.includes("ko-KR")),
|
||||
"expected live requests to pin Korean-language payloads",
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getKBLSummary skips the standings endpoint when includeStandings is false", async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
|
||||
global.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
calls.push(target);
|
||||
|
||||
if (target.includes("/match/list?")) {
|
||||
return makeResponse(schedulePayload);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${target}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const summary = await getKBLSummary("2026-04-01", {
|
||||
team: "KCC",
|
||||
includeStandings: false,
|
||||
});
|
||||
|
||||
assert.equal(summary.matches.length, 1);
|
||||
assert.equal(summary.standings, undefined);
|
||||
assert.deepEqual(
|
||||
calls.filter((target) => target.includes("/league/rank/team")),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getMatchResults rejects impossible calendar dates before fetching", async () => {
|
||||
let fetchCalled = false;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
getMatchResults("2026-13-40", {
|
||||
fetchImpl: async () => {
|
||||
fetchCalled = true;
|
||||
return makeResponse(schedulePayload);
|
||||
},
|
||||
}),
|
||||
/date must be a valid Date or YYYY-MM-DD string\./,
|
||||
);
|
||||
|
||||
assert.equal(fetchCalled, false);
|
||||
});
|
||||
|
||||
function makeResponse(body) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
7
packages/public-restroom-nearby/CHANGELOG.md
Normal file
7
packages/public-restroom-nearby/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# public-restroom-nearby
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Initial release: official public-restroom nearby lookup package for Korean location queries.
|
||||
138
packages/public-restroom-nearby/README.md
Normal file
138
packages/public-restroom-nearby/README.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# public-restroom-nearby
|
||||
|
||||
공식 `공중화장실정보` 표준데이터와 Kakao Map anchor 검색을 사용해 근처 공중화장실/개방화장실을 찾는 Node.js 패키지입니다.
|
||||
|
||||
## 설치
|
||||
|
||||
배포 후:
|
||||
|
||||
```bash
|
||||
npm install public-restroom-nearby
|
||||
```
|
||||
|
||||
이 저장소에서 개발할 때:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 사용 원칙
|
||||
|
||||
- 유저 위치는 자동으로 추적하지 않습니다.
|
||||
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
|
||||
- 화장실 데이터는 공식 `공중화장실정보` CSV를 직접 사용합니다.
|
||||
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 구하고, 가능하면 해당 시도 CSV로 좁혀서 조회합니다.
|
||||
|
||||
## 공식 표면
|
||||
|
||||
- 공공데이터포털 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
for (const item of result.items) {
|
||||
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
좌표를 직접 받은 경우:
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByCoordinates } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57103,
|
||||
longitude: 126.97679,
|
||||
limit: 3
|
||||
});
|
||||
|
||||
console.log(result.items);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
거리 제한이 필요하면 `maxDistanceMeters` 를 함께 넘겨서 반경 바깥 결과를 잘라낼 수 있습니다.
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3,
|
||||
maxDistanceMeters: 100
|
||||
});
|
||||
|
||||
console.log(`100m 이내 결과 수: ${result.meta.total}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Live smoke snapshot
|
||||
|
||||
2026-04-16 에 `광화문`, `limit=3` 로 실제 호출했을 때 상위 결과 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": {
|
||||
"name": "광화문",
|
||||
"address": "서울 종로구 사직로 161 (세종로)"
|
||||
},
|
||||
"meta": {
|
||||
"region": {
|
||||
"name": "서울특별시"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "세종로공영주차장",
|
||||
"type": "개방화장실"
|
||||
},
|
||||
{
|
||||
"name": "종로구청화장실",
|
||||
"type": "개방화장실"
|
||||
},
|
||||
{
|
||||
"name": "세종문화회관 화장실",
|
||||
"type": "개방화장실"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 로 확인했을 때는 `meta.total = 0` 으로 100m 이내 결과만 남도록 동작했습니다.
|
||||
|
||||
## 공개 API
|
||||
|
||||
- `parseCoordinateQuery(locationQuery)`
|
||||
- `inferRegion(address)`
|
||||
- `buildDatasetDownloadUrl(options?)`
|
||||
- `normalizePublicRestroomRows(csvText, origin, options?)`
|
||||
- `searchNearbyPublicRestroomsByCoordinates(options)`
|
||||
- `searchNearbyPublicRestroomsByLocationQuery(locationQuery, options?)`
|
||||
32
packages/public-restroom-nearby/package.json
Normal file
32
packages/public-restroom-nearby/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "public-restroom-nearby",
|
||||
"version": "0.1.0",
|
||||
"description": "Official public restroom standard-data client for nearby restroom lookup from a user-provided Korean location",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
||||
},
|
||||
"keywords": [
|
||||
"k-skill",
|
||||
"korea",
|
||||
"restroom",
|
||||
"toilet",
|
||||
"public-restroom"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
220
packages/public-restroom-nearby/src/index.js
Normal file
220
packages/public-restroom-nearby/src/index.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
const {
|
||||
buildDatasetDownloadUrl,
|
||||
decodeDatasetBuffer,
|
||||
extractDistrict,
|
||||
inferRegion,
|
||||
normalizeAnchorPanel,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
parseSearchResultsHtml,
|
||||
rankAnchorCandidates
|
||||
} = require("./parse");
|
||||
|
||||
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
|
||||
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
|
||||
const DEFAULT_BROWSER_HEADERS = {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
};
|
||||
const DEFAULT_PANEL_HEADERS = {
|
||||
...DEFAULT_BROWSER_HEADERS,
|
||||
accept: "application/json, text/plain, */*",
|
||||
appVersion: "6.6.0",
|
||||
origin: "https://place.map.kakao.com",
|
||||
pf: "PC",
|
||||
referer: "https://place.map.kakao.com/"
|
||||
};
|
||||
|
||||
async function request(url, options = {}, responseType = "text") {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
...(options.headerSet || DEFAULT_BROWSER_HEADERS),
|
||||
...(options.headers || {})
|
||||
},
|
||||
signal: options.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Request failed with ${response.status} for ${url}`);
|
||||
error.status = response.status;
|
||||
error.url = url;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (responseType === "json") {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
if (responseType === "buffer") {
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query, options = {}) {
|
||||
const url = new URL(SEARCH_VIEW_URL);
|
||||
url.searchParams.set("q", String(query || "").trim());
|
||||
|
||||
return request(url.toString(), options, "text");
|
||||
}
|
||||
|
||||
async function fetchPlacePanel(confirmId, options = {}) {
|
||||
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
|
||||
}
|
||||
|
||||
function isRecoverablePlacePanelError(error) {
|
||||
const status = Number(error?.status);
|
||||
|
||||
return Number.isInteger(status) && status >= 400 && status < 600;
|
||||
}
|
||||
|
||||
async function resolveAnchor(locationQuery, options = {}) {
|
||||
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
|
||||
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
|
||||
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
|
||||
|
||||
for (const candidate of rankedCandidates) {
|
||||
let anchorPanel;
|
||||
|
||||
try {
|
||||
anchorPanel = await fetchPlacePanel(candidate.id, options);
|
||||
} catch (error) {
|
||||
if (isRecoverablePlacePanelError(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
|
||||
|
||||
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
|
||||
return {
|
||||
anchor,
|
||||
candidates: rankedCandidates
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
|
||||
}
|
||||
|
||||
async function fetchDatasetCsv(options = {}) {
|
||||
const datasetUrl = buildDatasetDownloadUrl(options);
|
||||
const buffer = await request(
|
||||
datasetUrl,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
referer: "https://file.localdata.go.kr/file/public_restroom_info/info",
|
||||
...(options.headers || {})
|
||||
}
|
||||
},
|
||||
"buffer",
|
||||
);
|
||||
|
||||
return {
|
||||
datasetUrl,
|
||||
csvText: decodeDatasetBuffer(buffer)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
if (limit === undefined || limit === null) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
const parsed = Number(limit);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error("limit must be a positive number.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
|
||||
const latitude = Number(options.latitude);
|
||||
const longitude = Number(options.longitude);
|
||||
const limit = normalizeLimit(options.limit);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("latitude and longitude must be finite numbers.");
|
||||
}
|
||||
|
||||
const dataset = await fetchDatasetCsv(options);
|
||||
const allItems = normalizePublicRestroomRows(dataset.csvText, { latitude, longitude }, {
|
||||
maxDistanceMeters: options.maxDistanceMeters,
|
||||
preferredDistrict: options.preferredDistrict
|
||||
});
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
name: options.anchorName || "입력 좌표",
|
||||
address: options.anchorAddress || null,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
items: allItems.slice(0, limit),
|
||||
meta: {
|
||||
total: allItems.length,
|
||||
limit,
|
||||
datasetUrl: dataset.datasetUrl,
|
||||
region: options.region || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function searchNearbyPublicRestroomsByLocationQuery(locationQuery, options = {}) {
|
||||
const coordinateQuery = parseCoordinateQuery(locationQuery);
|
||||
|
||||
if (coordinateQuery) {
|
||||
return searchNearbyPublicRestroomsByCoordinates({
|
||||
...options,
|
||||
...coordinateQuery,
|
||||
anchorName: String(locationQuery || "").trim()
|
||||
});
|
||||
}
|
||||
|
||||
const { anchor, candidates } = await resolveAnchor(locationQuery, options);
|
||||
const region = inferRegion(anchor.address);
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
...options,
|
||||
latitude: anchor.latitude,
|
||||
longitude: anchor.longitude,
|
||||
orgCode: options.orgCode || region?.orgCode,
|
||||
region,
|
||||
preferredDistrict: options.preferredDistrict || extractDistrict(anchor.address),
|
||||
anchorName: anchor.name,
|
||||
anchorAddress: anchor.address
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
anchor,
|
||||
candidates,
|
||||
meta: {
|
||||
...result.meta,
|
||||
region
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDatasetDownloadUrl,
|
||||
inferRegion,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
searchNearbyPublicRestroomsByCoordinates,
|
||||
searchNearbyPublicRestroomsByLocationQuery
|
||||
};
|
||||
408
packages/public-restroom-nearby/src/parse.js
Normal file
408
packages/public-restroom-nearby/src/parse.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
const { TextDecoder } = require("node:util");
|
||||
|
||||
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
|
||||
const TAG_PATTERN = /<[^>]+>/g;
|
||||
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
|
||||
|
||||
const REGION_ENTRIES = [
|
||||
["서울특별시", "6110000_ALL", ["서울특별시", "서울"]],
|
||||
["부산광역시", "6260000_ALL", ["부산광역시", "부산"]],
|
||||
["대구광역시", "6270000_ALL", ["대구광역시", "대구"]],
|
||||
["인천광역시", "6280000_ALL", ["인천광역시", "인천"]],
|
||||
["광주광역시", "6290000_ALL", ["광주광역시", "광주"]],
|
||||
["대전광역시", "6300000_ALL", ["대전광역시", "대전"]],
|
||||
["울산광역시", "6310000_ALL", ["울산광역시", "울산"]],
|
||||
["세종특별자치시", "5690000_ALL", ["세종특별자치시", "세종"]],
|
||||
["경기도", "6410000_ALL", ["경기도", "경기"]],
|
||||
["강원특별자치도", "6530000_ALL", ["강원특별자치도", "강원도", "강원"]],
|
||||
["충청북도", "6430000_ALL", ["충청북도", "충북"]],
|
||||
["충청남도", "6440000_ALL", ["충청남도", "충남"]],
|
||||
["전북특별자치도", "6540000_ALL", ["전북특별자치도", "전라북도", "전북"]],
|
||||
["전라남도", "6460000_ALL", ["전라남도", "전남"]],
|
||||
["경상북도", "6470000_ALL", ["경상북도", "경북"]],
|
||||
["경상남도", "6480000_ALL", ["경상남도", "경남"]],
|
||||
["제주특별자치도", "6500000_ALL", ["제주특별자치도", "제주도", "제주"]],
|
||||
];
|
||||
|
||||
function decodeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(NON_WORD_PATTERN, "");
|
||||
}
|
||||
|
||||
function extractAttribute(fragment, name) {
|
||||
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
|
||||
return match ? decodeHtml(match[1]).trim() : "";
|
||||
}
|
||||
|
||||
function extractInnerText(fragment, className) {
|
||||
const match = fragment.match(
|
||||
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
|
||||
);
|
||||
|
||||
return match ? stripTags(match[1]) : "";
|
||||
}
|
||||
|
||||
function parseSearchResultsHtml(html) {
|
||||
const items = [];
|
||||
let match;
|
||||
|
||||
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
|
||||
const fragment = match[1];
|
||||
const id = extractAttribute(fragment, "data-id");
|
||||
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
|
||||
|
||||
if (!id || !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
|
||||
.map((entry) => stripTags(entry[1]))
|
||||
.filter(Boolean);
|
||||
|
||||
items.push({
|
||||
id,
|
||||
name,
|
||||
category: extractInnerText(fragment, "txt_ginfo"),
|
||||
address: addressMatches.at(-1) || ""
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function scoreAnchorCandidate(query, item) {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
const normalizedName = normalizeText(item.name);
|
||||
const normalizedAddress = normalizeText(item.address);
|
||||
let score = 0;
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return score;
|
||||
}
|
||||
|
||||
if (normalizedName === normalizedQuery) {
|
||||
score += 1000;
|
||||
}
|
||||
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
score += 800;
|
||||
}
|
||||
|
||||
if (normalizedName.includes(normalizedQuery)) {
|
||||
score += 600;
|
||||
}
|
||||
|
||||
if (normalizedAddress.includes(normalizedQuery)) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function rankAnchorCandidates(query, items) {
|
||||
return [...(items || [])].sort((left, right) => {
|
||||
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
|
||||
|
||||
if (scoreDelta !== 0) {
|
||||
return scoreDelta;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, "ko");
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAnchorPanel(panel, searchItem = {}) {
|
||||
const summary = panel.summary || {};
|
||||
|
||||
return {
|
||||
id: String(summary.confirm_id || searchItem.id || ""),
|
||||
name: summary.name || searchItem.name || "",
|
||||
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
|
||||
address: summary.address?.disp || searchItem.address || "",
|
||||
latitude: toNumber(summary.point?.lat),
|
||||
longitude: toNumber(summary.point?.lon),
|
||||
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
|
||||
};
|
||||
}
|
||||
|
||||
function parseCoordinateQuery(locationQuery) {
|
||||
const match = String(locationQuery || "")
|
||||
.trim()
|
||||
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: Number(match[1]),
|
||||
longitude: Number(match[2])
|
||||
};
|
||||
}
|
||||
|
||||
function inferRegion(value) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
for (const [name, orgCode, aliases] of REGION_ENTRIES) {
|
||||
for (const alias of aliases) {
|
||||
if (normalized.startsWith(normalizeText(alias))) {
|
||||
return { name, orgCode };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDatasetDownloadUrl(options = {}) {
|
||||
const url = new URL("https://file.localdata.go.kr/file/download/public_restroom_info/info");
|
||||
|
||||
if (options.orgCode) {
|
||||
url.searchParams.set("orgCode", options.orgCode);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function decodeDatasetBuffer(buffer) {
|
||||
const asUtf8 = Buffer.from(buffer).toString("utf8");
|
||||
|
||||
if (asUtf8.includes("개방자치단체코드") && asUtf8.includes("화장실명")) {
|
||||
return asUtf8;
|
||||
}
|
||||
|
||||
return new TextDecoder("euc-kr").decode(buffer);
|
||||
}
|
||||
|
||||
function parseCsv(csvText) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let value = "";
|
||||
let inQuotes = false;
|
||||
|
||||
const text = String(csvText || "");
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const nextCharacter = text[index + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (character === '"' && nextCharacter === '"') {
|
||||
value += '"';
|
||||
index += 1;
|
||||
} else if (character === '"') {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
value += character;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inQuotes = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ",") {
|
||||
row.push(value);
|
||||
value = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\n") {
|
||||
row.push(value.replace(/\r$/u, ""));
|
||||
rows.push(row);
|
||||
row = [];
|
||||
value = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
value += character;
|
||||
}
|
||||
|
||||
if (value || row.length > 0) {
|
||||
row.push(value.replace(/\r$/u, ""));
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const [headerRow, ...dataRows] = rows.filter((entry) => entry.some((cell) => cell !== ""));
|
||||
|
||||
if (!headerRow || headerRow.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dataRows.map((cells) => {
|
||||
const record = {};
|
||||
|
||||
for (let index = 0; index < headerRow.length; index += 1) {
|
||||
record[headerRow[index]] = cells[index] ?? "";
|
||||
}
|
||||
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(String(value).replace(/,/g, ""));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toBooleanYesNo(value) {
|
||||
return String(value || "").trim().toUpperCase() === "Y";
|
||||
}
|
||||
|
||||
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
const toRadians = (value) => (value * Math.PI) / 180;
|
||||
const deltaLatitude = toRadians(latitudeB - latitudeA);
|
||||
const deltaLongitude = toRadians(longitudeB - longitudeA);
|
||||
const originLatitude = toRadians(latitudeA);
|
||||
const targetLatitude = toRadians(latitudeB);
|
||||
|
||||
const value =
|
||||
Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
|
||||
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function buildMapUrl(name, latitude, longitude) {
|
||||
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
|
||||
}
|
||||
|
||||
function extractDistrict(address) {
|
||||
const match = String(address || "")
|
||||
.trim()
|
||||
.match(/^(?:\S+)\s+(\S+(?:구|군|시))/u);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function normalizePublicRestroomRows(csvText, origin, options = {}) {
|
||||
const latitude = Number(origin?.latitude);
|
||||
const longitude = Number(origin?.longitude);
|
||||
const limit = options.limit ?? null;
|
||||
const maxDistanceMeters = Number.isFinite(Number(options.maxDistanceMeters))
|
||||
? Number(options.maxDistanceMeters)
|
||||
: null;
|
||||
const preferredDistrict = String(options.preferredDistrict || "").trim() || null;
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("normalizePublicRestroomRows requires finite origin coordinates.");
|
||||
}
|
||||
|
||||
const items = parseCsv(csvText)
|
||||
.map((row) => {
|
||||
const itemLatitude = toNumber(row["WGS84위도"]);
|
||||
const itemLongitude = toNumber(row["WGS84경도"]);
|
||||
|
||||
if (!Number.isFinite(itemLatitude) || !Number.isFinite(itemLongitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distanceMeters = haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude);
|
||||
const roadAddress = String(row["소재지도로명주소"] || "").trim();
|
||||
const lotAddress = String(row["소재지지번주소"] || "").trim();
|
||||
const address = roadAddress || lotAddress;
|
||||
|
||||
return {
|
||||
id: String(row["관리번호"] || "").trim(),
|
||||
name: String(row["화장실명"] || "").trim(),
|
||||
type: String(row["구분명"] || "").trim(),
|
||||
address,
|
||||
roadAddress: roadAddress || null,
|
||||
lotAddress: lotAddress || null,
|
||||
latitude: itemLatitude,
|
||||
longitude: itemLongitude,
|
||||
distanceMeters,
|
||||
phone: String(row["전화번호"] || "").trim() || null,
|
||||
managementAgency: String(row["관리기관명"] || "").trim() || null,
|
||||
openTimeCategory: String(row["개방시간"] || "").trim() || null,
|
||||
openTimeDetail: String(row["개방시간상세"] || "").trim() || null,
|
||||
hasEmergencyBell: toBooleanYesNo(row["비상벨설치여부"]),
|
||||
hasBabyChangingTable: toBooleanYesNo(row["기저귀교환대유무"]),
|
||||
hasAccessibleFacility:
|
||||
(toNumber(row["남성용-장애인용대변기수"]) || 0) +
|
||||
(toNumber(row["남성용-장애인용소변기수"]) || 0) +
|
||||
(toNumber(row["여성용-장애인용대변기수"]) || 0) >
|
||||
0,
|
||||
mapUrl: buildMapUrl(String(row["화장실명"] || "").trim(), itemLatitude, itemLongitude)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((item) => (maxDistanceMeters === null ? true : item.distanceMeters <= maxDistanceMeters))
|
||||
.sort((left, right) => {
|
||||
if (preferredDistrict) {
|
||||
const leftMatchesDistrict = extractDistrict(left.address) === preferredDistrict;
|
||||
const rightMatchesDistrict = extractDistrict(right.address) === preferredDistrict;
|
||||
|
||||
if (leftMatchesDistrict !== rightMatchesDistrict) {
|
||||
return leftMatchesDistrict ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (left.distanceMeters !== right.distanceMeters) {
|
||||
return left.distanceMeters - right.distanceMeters;
|
||||
}
|
||||
|
||||
if (left.type !== right.type) {
|
||||
return left.type.localeCompare(right.type, "ko");
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, "ko");
|
||||
});
|
||||
|
||||
const dedupedItems = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of items) {
|
||||
const key = [item.name, item.address, item.latitude, item.longitude, item.type].join("::");
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
dedupedItems.push(item);
|
||||
}
|
||||
|
||||
if (limit === null) {
|
||||
return dedupedItems;
|
||||
}
|
||||
|
||||
return dedupedItems.slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDatasetDownloadUrl,
|
||||
decodeDatasetBuffer,
|
||||
extractDistrict,
|
||||
inferRegion,
|
||||
normalizeAnchorPanel,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
parseSearchResultsHtml,
|
||||
rankAnchorCandidates
|
||||
};
|
||||
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"summary": {
|
||||
"confirm_id": "1001",
|
||||
"name": "광화문",
|
||||
"address": {
|
||||
"disp": "서울특별시 종로구 세종대로 172"
|
||||
},
|
||||
"point": {
|
||||
"lat": 37.57103,
|
||||
"lon": 126.97679
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal file
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<ul>
|
||||
<li class="search_item base" data-id="1001" data-title="광화문">
|
||||
<strong class="tit_g">광화문</strong>
|
||||
<span class="txt_ginfo">역사유적지</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
|
||||
</li>
|
||||
</ul>
|
||||
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal file
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
개방자치단체코드,관리번호,구분명,근거법령명,화장실명,소재지도로명주소,소재지지번주소,남성용-대변기수,남성용-소변기수,남성용-장애인용대변기수,남성용-장애인용소변기수,남성용-어린이용대변기수,남성용-어린이용소변기수,여성용-대변기수,여성용-장애인용대변기수,여성용-어린이용대변기수,관리기관명,전화번호,개방시간,개방시간상세,설치연월,WGS84위도,WGS84경도,화장실소유구분명,오물처리방식,안전관리시설설치대상여부,비상벨설치여부,비상벨설치장소,화장실입구CCTV설치유무,기저귀교환대유무,기저귀교환대장소,리모델링연월,데이터기준일자,데이터갱신구분,데이터갱신시점,최종수정시점
|
||||
3000000,202530000000100842,개방화장실,법제3조제16호-영제3조제1항제1호,종로문화체육센터,서울특별시 종로구 인왕산로1길 21,서울특별시 종로구 사직동 284-1,2,3,1,1,1,1,7,1,1,종로구시설관리공단 건강사업부,027329393,정시,09:00~18:00,,37.57428,126.96468,공공기관-지방공공기관(지방공기업/지방출자출연기관),수세식,Y,Y,장애인화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
|
||||
3000000,202530000000100863,공중화장실,법제3조제16호-영제3조제1항제1호,통인시장 고객만족센터,서울특별시 종로구 자하문로 15길 18,서울특별시 종로구 통인동 10-3,2,3,1,0,0,0,4,1,0,통인시장 상인회,027220911,정시,11:00~16:00,201002,37.58077,126.96995,공공기관-지방자치단체,수세식,Y,Y,장애인화장실+남자화장실+여자화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:28:39,2025-11-10 09:45:40
|
||||
3000000,202530000000100830,개방화장실,법제3조제16호-영제3조제1항제1호,보건소,서울특별시 종로구 자하문로19길 36,서울특별시 종로구 옥인동 45-30,7,7,0,0,0,0,10,0,0,종로보건소,0221483520,정시,평일9시간(09:00~18:00),,37.58177,126.96926,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
|
||||
|
283
packages/public-restroom-nearby/test/index.test.js
Normal file
283
packages/public-restroom-nearby/test/index.test.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
buildDatasetDownloadUrl,
|
||||
inferRegion,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
searchNearbyPublicRestroomsByCoordinates,
|
||||
searchNearbyPublicRestroomsByLocationQuery
|
||||
} = require("../src/index");
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
|
||||
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
|
||||
const csvFixture = fs.readFileSync(path.join(fixturesDir, "public-restrooms-seoul.csv"), "utf8");
|
||||
|
||||
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
|
||||
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
|
||||
latitude: 37.573713,
|
||||
longitude: 126.978338
|
||||
});
|
||||
assert.equal(parseCoordinateQuery("광화문"), null);
|
||||
});
|
||||
|
||||
test("inferRegion maps Korean region names to the official localdata orgCode", () => {
|
||||
assert.deepEqual(inferRegion("서울특별시 종로구 세종대로"), {
|
||||
name: "서울특별시",
|
||||
orgCode: "6110000_ALL"
|
||||
});
|
||||
assert.deepEqual(inferRegion("경기도 성남시 분당구"), {
|
||||
name: "경기도",
|
||||
orgCode: "6410000_ALL"
|
||||
});
|
||||
assert.equal(inferRegion("미상 주소"), null);
|
||||
});
|
||||
|
||||
test("buildDatasetDownloadUrl defaults to the nationwide CSV and supports regional narrowing", () => {
|
||||
assert.equal(
|
||||
buildDatasetDownloadUrl(),
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info"
|
||||
);
|
||||
assert.equal(
|
||||
buildDatasetDownloadUrl({ orgCode: "6110000_ALL" }),
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows keeps useful restroom metadata and sorts by distance", () => {
|
||||
const items = normalizePublicRestroomRows(csvFixture, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
});
|
||||
|
||||
assert.equal(items.length, 3);
|
||||
assert.deepEqual(
|
||||
items.map((item) => [item.id, item.name, item.type, item.address]),
|
||||
[
|
||||
["202530000000100863", "통인시장 고객만족센터", "공중화장실", "서울특별시 종로구 자하문로 15길 18"],
|
||||
["202530000000100830", "보건소", "개방화장실", "서울특별시 종로구 자하문로19길 36"],
|
||||
["202530000000100842", "종로문화체육센터", "개방화장실", "서울특별시 종로구 인왕산로1길 21"]
|
||||
]
|
||||
);
|
||||
assert.ok(items[0].distanceMeters < items[1].distanceMeters);
|
||||
const cultureCenter = items.find((item) => item.id === "202530000000100842");
|
||||
assert.equal(cultureCenter.openTimeDetail, "09:00~18:00");
|
||||
assert.equal(
|
||||
cultureCenter.mapUrl,
|
||||
"https://map.kakao.com/link/map/%EC%A2%85%EB%A1%9C%EB%AC%B8%ED%99%94%EC%B2%B4%EC%9C%A1%EC%84%BC%ED%84%B0,37.57428,126.96468"
|
||||
);
|
||||
assert.equal(cultureCenter.hasBabyChangingTable, true);
|
||||
assert.equal(cultureCenter.hasEmergencyBell, true);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows collapses identical restroom rows from the official CSV", () => {
|
||||
const duplicatedCsv = `${csvFixture.trim()}\n${csvFixture.trim().split("\n")[1]}\n`;
|
||||
const items = normalizePublicRestroomRows(duplicatedCsv, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
});
|
||||
|
||||
assert.equal(items.length, 3);
|
||||
assert.equal(
|
||||
items.filter((item) => item.id === "202530000000100842").length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows can prefer the anchor district over suspicious cross-district coordinates", () => {
|
||||
const weightedCsv = `${csvFixture.trim()}\n3000000,999999999999999999,개방화장실,법제3조제16호-영제3조제1항제1호,멀리있는구청,서울특별시 서대문구 통일로 1,서울특별시 서대문구 냉천동 1,1,1,0,0,0,0,1,0,0,테스트기관,0212345678,정시,09:00~18:00,,37.57372,126.97834,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40\n`;
|
||||
const items = normalizePublicRestroomRows(weightedCsv, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
}, {
|
||||
preferredDistrict: "종로구"
|
||||
});
|
||||
|
||||
assert.notEqual(items[0].name, "멀리있는구청");
|
||||
assert.equal(items[0].address.includes("종로구"), true);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByCoordinates queries the official CSV and returns nearest normalized items", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(String(url));
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944,
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0].name, "통인시장 고객만족센터");
|
||||
assert.equal(result.meta.datasetUrl, "https://file.localdata.go.kr/file/download/public_restroom_info/info");
|
||||
assert.deepEqual(calls, ["https://file.localdata.go.kr/file/download/public_restroom_info/info"]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByCoordinates forwards maxDistanceMeters to the CSV normalization path", async () => {
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944,
|
||||
limit: 5,
|
||||
maxDistanceMeters: 100,
|
||||
fetchImpl: async () => makeResponse(Buffer.from(csvFixture, "utf8"))
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 0);
|
||||
assert.equal(result.meta.total, 0);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery resolves a Kakao anchor, narrows to the regional CSV, and returns nearest restrooms", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
calls.push(resolved);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(anchorSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
return makeResponse(anchorPanel, "application/json");
|
||||
}
|
||||
|
||||
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.anchor.name, "광화문");
|
||||
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
|
||||
assert.equal(result.meta.region.name, "서울특별시");
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0].name, "종로문화체육센터");
|
||||
assert.deepEqual(calls, [
|
||||
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
|
||||
"https://place-api.map.kakao.com/places/panel3/1001",
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery falls through to later Kakao candidates when a panel request fails", async () => {
|
||||
const calls = [];
|
||||
const multiCandidateSearchHtml = `
|
||||
<ul>
|
||||
<li class="search_item base" data-id="1001" data-title="광화문">
|
||||
<strong class="tit_g">광화문</strong>
|
||||
<span class="txt_ginfo">역사유적지</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
|
||||
</li>
|
||||
<li class="search_item base" data-id="1002" data-title="광화문광장">
|
||||
<strong class="tit_g">광화문광장</strong>
|
||||
<span class="txt_ginfo">광장</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
const fallbackPanel = {
|
||||
summary: {
|
||||
...anchorPanel.summary,
|
||||
confirm_id: "1002",
|
||||
name: "광화문광장"
|
||||
}
|
||||
};
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
calls.push(resolved);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(multiCandidateSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
return { ok: false, status: 500 };
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1002") {
|
||||
return makeResponse(fallbackPanel, "application/json");
|
||||
}
|
||||
|
||||
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.anchor.id, "1002");
|
||||
assert.equal(result.anchor.name, "광화문광장");
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.deepEqual(calls, [
|
||||
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
|
||||
"https://place-api.map.kakao.com/places/panel3/1001",
|
||||
"https://place-api.map.kakao.com/places/panel3/1002",
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery still surfaces non-HTTP Kakao panel errors", async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(anchorSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
throw new Error("socket hang up");
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
fetchImpl
|
||||
}),
|
||||
/socket hang up/
|
||||
);
|
||||
});
|
||||
|
||||
function makeResponse(body, contentType = "text/csv;charset=UTF-8") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
if (String(name).toLowerCase() === "content-type") {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
|
||||
},
|
||||
async json() {
|
||||
return typeof body === "string" ? JSON.parse(body) : body;
|
||||
},
|
||||
async arrayBuffer() {
|
||||
return Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
|
||||
}
|
||||
};
|
||||
}
|
||||
95
public-restroom-nearby/SKILL.md
Normal file
95
public-restroom-nearby/SKILL.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
name: public-restroom-nearby
|
||||
description: Use when the user asks for nearby public/open restrooms or 근처 화장실. Always ask the user's current location first, then use the official nationwide public-restroom standard dataset plus Kakao anchor resolution.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: convenience
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Public Restroom Nearby
|
||||
|
||||
## What this skill does
|
||||
|
||||
유저가 알려준 현재 위치를 기준으로 **근처 공중화장실 / 개방화장실** 을 찾는다.
|
||||
|
||||
- 위치는 자동으로 추정하지 않는다.
|
||||
- **반드시 먼저 현재 위치를 질문**한다.
|
||||
- 화장실 데이터는 공식 `공중화장실정보` 표준데이터를 사용한다.
|
||||
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡고, 가능한 경우 해당 시도 데이터만 좁혀서 조회한다.
|
||||
- 좌표를 직접 받으면 바로 nearby 계산으로 들어간다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "근처 화장실 찾아줘"
|
||||
- "서울역 근처 공중화장실 있어?"
|
||||
- "광화문 주변 개방화장실 몇 군데만 보여줘"
|
||||
- "지금 여기서 가까운 화장실 지도 링크 줘"
|
||||
|
||||
## Mandatory first question
|
||||
|
||||
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
|
||||
|
||||
- 권장 질문: `현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.`
|
||||
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 유저에게 반드시 현재 위치를 묻는다.
|
||||
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보한다.
|
||||
3. anchor 주소에서 시도(서울/경기/부산 등)를 추론할 수 있으면 해당 지역 CSV로 좁힌다.
|
||||
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬한다.
|
||||
5. 보통 3~5개만 짧게 정리하고, 필요하면 지도 링크(`map.kakao.com/link/map/...`)를 같이 준다.
|
||||
|
||||
## Responding
|
||||
|
||||
결과는 보통 아래 필드를 포함해 짧게 정리한다.
|
||||
|
||||
- 화장실명
|
||||
- 구분명(공중화장실 / 개방화장실)
|
||||
- 거리
|
||||
- 주소
|
||||
- 개방시간/상세
|
||||
- 지도 링크
|
||||
|
||||
## Node.js example
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
console.log(result.anchor);
|
||||
console.log(result.items);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- 유저의 현재 위치를 먼저 확인했다.
|
||||
- 공식 데이터 기반으로 최소 1개 이상 nearby restroom 을 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
|
||||
- 가장 가까운 결과를 3~5개 이내로 정리했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Kakao Map anchor 가 애매하면 위치 기준점이 흔들릴 수 있다.
|
||||
- 공개 표준데이터는 실시간 점유/잠금 상태를 주지 않으므로 개방시간 중심으로만 안내해야 한다.
|
||||
- CSV 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.
|
||||
138
scripts/fixtures/hola-poke-yeoksam-contract-smoke.json
Normal file
138
scripts/fixtures/hola-poke-yeoksam-contract-smoke.json
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
{
|
||||
"verified_at": "2026-04-16 KST",
|
||||
"endpoint": "https://hola-poke-yeoksam-skill.onrender.com/mcp",
|
||||
"initialize": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": {
|
||||
"name": "hola-poke-yeoksam",
|
||||
"version": "3.2.3"
|
||||
}
|
||||
},
|
||||
"tools_list": {
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_menu",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_shop_info",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "enter_event",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"phone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"phone"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"outputSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_menu": {
|
||||
"updated_at": "2026-04-13",
|
||||
"currency": "KRW",
|
||||
"price_unit": "천원",
|
||||
"signature_poke": [
|
||||
{
|
||||
"id": 2,
|
||||
"name": "갈릭 쉬림프 포케",
|
||||
"price": 11.5,
|
||||
"tags": [
|
||||
"BEST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "아보카도 포케",
|
||||
"price": 10.5,
|
||||
"tags": [
|
||||
"VEGAN"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sets": [
|
||||
{
|
||||
"name": "1인 포케+스프 세트",
|
||||
"items": "포케 + 스프",
|
||||
"price": 13.5,
|
||||
"price_note": "13.5~"
|
||||
},
|
||||
{
|
||||
"name": "1인 혼밥 든든세트",
|
||||
"items": "포케 + 스프 + 음료",
|
||||
"price": 15.5,
|
||||
"price_note": "15.5~"
|
||||
}
|
||||
],
|
||||
"addons": [
|
||||
{
|
||||
"name": "아보카도",
|
||||
"price": 3.5
|
||||
},
|
||||
{
|
||||
"name": "메밀면",
|
||||
"price": 1.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"get_shop_info": {
|
||||
"name": "올라포케 역삼점",
|
||||
"address_road": "서울 강남구 논현로95길 29-8 1층 102호",
|
||||
"hours": {
|
||||
"weekday": "10:30 - 20:30",
|
||||
"break_time": "15:00 - 17:00",
|
||||
"weekend": "영업시간 네이버 스마트플레이스 확인"
|
||||
},
|
||||
"delivery_radius_km": 3,
|
||||
"group_order_url": "",
|
||||
"group_order_note": "10만원 이상 단체주문은 네이버 단체주문 페이지에서 메뉴 선택 후 네이버페이 결제. 결제 완료 시 예약 확정.",
|
||||
"delivery_apps": [
|
||||
"배달의민족",
|
||||
"쿠팡이츠",
|
||||
"요기요"
|
||||
]
|
||||
},
|
||||
"enter_event_success_contract": {
|
||||
"required_fields": [
|
||||
"message",
|
||||
"code",
|
||||
"next_action"
|
||||
],
|
||||
"accepts": [
|
||||
"01012345678",
|
||||
"010-1234-5678"
|
||||
],
|
||||
"stores_name_or_email": false
|
||||
},
|
||||
"enter_event_invalid_phone": {
|
||||
"error": "phone_format",
|
||||
"message": "번호는 010으로 시작하는 11자리로 입력해주세요 (예: 01012345678 또는 010-1234-5678)."
|
||||
}
|
||||
}
|
||||
11
scripts/kakaotalk_mac.py
Normal file
11
scripts/kakaotalk_mac.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BUNDLED_HELPER = Path(__file__).resolve().parent.parent / "kakaotalk-mac" / "scripts" / "kakaotalk_mac.py"
|
||||
|
||||
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
|
||||
raise FileNotFoundError(f"Bundled KakaoTalk helper not found: {_BUNDLED_HELPER}")
|
||||
|
||||
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())
|
||||
|
|
@ -152,7 +152,7 @@ test("README advertises OpenClaw among the supported coding agents", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("hwp skill documents environment-aware routing and supported operations", () => {
|
||||
test("hwp skill documents kordoc-based parsing and supported operations", () => {
|
||||
const skillPath = path.join(repoRoot, "hwp", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected hwp/SKILL.md to exist");
|
||||
|
|
@ -160,25 +160,57 @@ test("hwp skill documents environment-aware routing and supported operations", (
|
|||
const skill = read(path.join("hwp", "SKILL.md"));
|
||||
|
||||
assert.match(skill, /^name: hwp$/m);
|
||||
assert.match(skill, /@ohah\/hwpjs/);
|
||||
assert.match(skill, /\bhwp-mcp\b/);
|
||||
assert.match(skill, /Windows/i);
|
||||
assert.match(skill, /\bkordoc\b/);
|
||||
assert.doesNotMatch(skill, /@ohah\/hwpjs/);
|
||||
assert.doesNotMatch(skill, /\bhwp-mcp\b/);
|
||||
assert.match(skill, /JSON/i);
|
||||
assert.match(skill, /Markdown/i);
|
||||
assert.match(skill, /HTML/i);
|
||||
assert.match(skill, /image/i);
|
||||
assert.match(skill, /batch/i);
|
||||
assert.match(skill, /(batch|배치)/i);
|
||||
assert.match(skill, /HWPX/i);
|
||||
assert.match(skill, /(역변환|되돌려)/);
|
||||
assert.match(skill, /(비교|compare)/i);
|
||||
assert.match(skill, /pdfjs-dist/);
|
||||
assert.match(skill, /(extractFormFields|양식 필드)/);
|
||||
assert.doesNotMatch(skill, /fillForm/);
|
||||
assert.doesNotMatch(skill, /kordoc fill/);
|
||||
assert.doesNotMatch(skill, /kordoc mcp/);
|
||||
});
|
||||
|
||||
test("hwp skill documents inline image verification for markdown output", () => {
|
||||
test("hwp docs match the published kordoc install and runtime contract", () => {
|
||||
const skill = read(path.join("hwp", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "hwp.md"));
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const readme = read("README.md");
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
|
||||
assert.match(skill, /hwpjs to-markdown document\.hwp -o output\.md --include-images/);
|
||||
assert.match(skill, /Markdown:.*(data:|base64)/);
|
||||
assert.match(skill, /--images-dir/);
|
||||
assert.doesNotMatch(skill, /Markdown:.*이미지 경로 생성 여부 확인/);
|
||||
assert.match(featureDoc, /--images-dir/);
|
||||
assert.match(skill, /npx --yes --package kordoc --package pdfjs-dist kordoc .* -o .*\.md/);
|
||||
assert.match(skill, /markdownToHwpx/);
|
||||
assert.match(skill, /extractFormFields/);
|
||||
assert.match(skill, /npm init -y/);
|
||||
assert.match(skill, /npm install kordoc pdfjs-dist/);
|
||||
assert.doesNotMatch(skill, /^\s*npx kordoc\b/m);
|
||||
assert.doesNotMatch(skill, /export NODE_PATH/);
|
||||
assert.match(featureDoc, /npx --yes --package kordoc --package pdfjs-dist kordoc .* --format json/);
|
||||
assert.match(featureDoc, /markdownToHwpx/);
|
||||
assert.match(featureDoc, /(extractFormFields|양식 필드)/);
|
||||
assert.match(featureDoc, /npx --yes --package kordoc --package pdfjs-dist kordoc watch/);
|
||||
assert.match(featureDoc, /npm init -y/);
|
||||
assert.match(featureDoc, /npm install kordoc pdfjs-dist/);
|
||||
assert.doesNotMatch(featureDoc, /^\s*npx kordoc\b/m);
|
||||
assert.doesNotMatch(featureDoc, /export NODE_PATH/);
|
||||
assert.match(featureDoc, /npm install -g kordoc pdfjs-dist/);
|
||||
assert.doesNotMatch(featureDoc, /선택적으로 `pdfjs-dist`/);
|
||||
assert.doesNotMatch(featureDoc, /kordoc fill/);
|
||||
assert.doesNotMatch(featureDoc, /kordoc mcp/);
|
||||
assert.doesNotMatch(featureDoc, /fillForm/);
|
||||
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(sources, /kordoc/);
|
||||
assert.match(sources, /pdfjs-dist/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the hwp skill", () => {
|
||||
|
|
@ -191,10 +223,11 @@ test("repository docs advertise the hwp skill", () => {
|
|||
assert.match(readme, /\| HWP 문서 처리 \|/);
|
||||
assert.match(readme, /\[HWP 문서 처리\]\(docs\/features\/hwp\.md\)/);
|
||||
assert.match(install, /--skill hwp/);
|
||||
assert.match(featureDoc, /--include-images/);
|
||||
assert.match(featureDoc, /(data:|base64)/);
|
||||
assert.match(featureDoc, /Markdown 출력.*(data:|base64)/);
|
||||
assert.doesNotMatch(featureDoc, /Markdown 출력.*이미지 (파일 )?경로 생성 여부 확인/);
|
||||
assert.match(featureDoc, /\bkordoc\b/);
|
||||
assert.doesNotMatch(featureDoc, /@ohah\/hwpjs/);
|
||||
assert.doesNotMatch(featureDoc, /\bhwp-mcp\b/);
|
||||
assert.match(install, /npm install -g kordoc /);
|
||||
assert.doesNotMatch(install, /@ohah\/hwpjs/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kakaotalk-mac skill", () => {
|
||||
|
|
@ -221,10 +254,34 @@ test("repository docs advertise the used-car-price-search skill", () => {
|
|||
assert.match(install, /--skill used-car-price-search/);
|
||||
assert.match(
|
||||
install,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
|
||||
/npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp/,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the public-restroom-nearby skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "public-restroom-nearby.md");
|
||||
const skillPath = path.join(repoRoot, "public-restroom-nearby", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/public-restroom-nearby.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected public-restroom-nearby/SKILL.md to exist");
|
||||
assert.match(readme, /\| 근처 공중화장실 찾기 \|/);
|
||||
assert.match(readme, /\[근처 공중화장실 찾기 가이드\]\(docs\/features\/public-restroom-nearby\.md\)/);
|
||||
assert.match(install, /--skill public-restroom-nearby/);
|
||||
assert.match(install, /npm install -g .*public-restroom-nearby/);
|
||||
});
|
||||
|
||||
test("public-restroom-nearby docs describe the maxDistanceMeters distance cap", () => {
|
||||
const featureDoc = read(path.join("docs", "features", "public-restroom-nearby.md"));
|
||||
const packageReadme = read(path.join("packages", "public-restroom-nearby", "README.md"));
|
||||
|
||||
assert.match(featureDoc, /maxDistanceMeters/);
|
||||
assert.match(featureDoc, /100m/);
|
||||
assert.match(packageReadme, /maxDistanceMeters/);
|
||||
assert.match(packageReadme, /100m/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the lck-analytics skill and package", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
@ -442,8 +499,11 @@ test("korea-weather docs route short-term forecast calls through the proxy witho
|
|||
|
||||
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
||||
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
||||
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
|
||||
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
|
||||
|
||||
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
|
||||
|
||||
|
|
@ -455,6 +515,17 @@ test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
|||
assert.match(skill, /Accessibility/i);
|
||||
assert.match(skill, /--me/);
|
||||
assert.match(skill, /confirm before sending/i);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
|
||||
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
|
||||
assert.match(doc, /cache|캐시/);
|
||||
assert.match(doc, /read-only|읽기 전용/i);
|
||||
assert.doesNotMatch(doc, /`query`/);
|
||||
}
|
||||
});
|
||||
|
||||
test("repository docs advertise the KTX booking skill as supported", () => {
|
||||
|
|
@ -1113,10 +1184,70 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
|
|||
assert.match(packageJson.scripts["pack:dry-run"], /workspace market-kurly-search/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kbl-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kbl-results skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "kbl-results.md");
|
||||
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kbl-results.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
||||
assert.match(readme, /\| KBL 경기 결과 조회 \|/);
|
||||
assert.match(readme, /\[KBL 경기 결과 가이드\]\(docs\/features\/kbl-results\.md\)/);
|
||||
assert.match(install, /--skill kbl-results/);
|
||||
assert.match(roadmap, /KBL 경기 결과 조회 스킬 출시/);
|
||||
assert.match(sources, /KBL 일정\/결과 API: https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(sources, /KBL 팀 순위 API: https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
||||
});
|
||||
|
||||
test("kbl-results skill documents the official JSON flow for date, team, and standings lookups", () => {
|
||||
const skillPath = path.join(repoRoot, "kbl-results", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kbl-results/SKILL.md to exist");
|
||||
|
||||
const skill = read(path.join("kbl-results", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "kbl-results.md"));
|
||||
|
||||
assert.match(skill, /^name: kbl-results$/m);
|
||||
assert.match(skill, /^description: .*KBL.*경기 결과.*순위.*$/m);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /YYYY-MM-DD/);
|
||||
assert.match(doc, /서울 SK|부산 KCC|팀 코드/);
|
||||
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(doc, /https:\/\/api\.kbl\.or\.kr\/league\/rank\/team/);
|
||||
assert.match(doc, /공식 JSON|공식 API|공식 표면/u);
|
||||
assert.match(doc, /현재 순위|standings/i);
|
||||
assert.match(doc, /kbl-results|KBL 경기 결과/u);
|
||||
}
|
||||
});
|
||||
|
||||
test("kbl-results package exports reusable results and standings helpers", () => {
|
||||
const pkg = require(path.join(repoRoot, "packages", "kbl-results", "src", "index.js"));
|
||||
|
||||
assert.equal(typeof pkg.getMatchResults, "function");
|
||||
assert.equal(typeof pkg.getStandings, "function");
|
||||
assert.equal(typeof pkg.getKBLSummary, "function");
|
||||
});
|
||||
|
||||
test("kbl-results package README stays aligned with the official KBL JSON lookup flow", () => {
|
||||
const packageReadme = read(path.join("packages", "kbl-results", "README.md"));
|
||||
|
||||
assert.match(packageReadme, /공식 KBL JSON 엔드포인트/u);
|
||||
assert.match(packageReadme, /api\.kbl\.or\.kr\/match\/list/);
|
||||
assert.match(packageReadme, /league\/rank\/team/);
|
||||
assert.match(packageReadme, /getKBLSummary/);
|
||||
assert.match(packageReadme, /서울 SK/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kleague-results skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
@ -1792,6 +1923,237 @@ test("repository docs advertise the real-estate-search skill and proxy-based app
|
|||
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "real-estate-search")), false);
|
||||
});
|
||||
|
||||
test("repository docs advertise the korean-scholarship-search skill and official-source workflow", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-scholarship-search.md");
|
||||
const featureDoc = read(path.join("docs", "features", "korean-scholarship-search.md"));
|
||||
const skillPath = path.join(repoRoot, "korean-scholarship-search", "SKILL.md");
|
||||
const skill = read(path.join("korean-scholarship-search", "SKILL.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
|
||||
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
|
||||
const searchCluesPath = path.join(repoRoot, "korean-scholarship-search", "references", "search-clues.md");
|
||||
const reportFormatPath = path.join(repoRoot, "korean-scholarship-search", "references", "report-format.md");
|
||||
const packageJson = readJson("package.json");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-scholarship-search.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected korean-scholarship-search/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(helperPath), "expected korean-scholarship-search/scripts/scholarship_filter.py to exist");
|
||||
assert.ok(fs.existsSync(plannerPath), "expected korean-scholarship-search/scripts/university_search_plan.py to exist");
|
||||
assert.ok(fs.existsSync(searchCluesPath), "expected korean-scholarship-search/references/search-clues.md to exist");
|
||||
assert.ok(fs.existsSync(reportFormatPath), "expected korean-scholarship-search/references/report-format.md to exist");
|
||||
|
||||
assert.match(readme, /\| 장학금 검색 및 조회 \|/);
|
||||
assert.match(readme, /\[장학금 검색 및 조회 가이드\]\(docs\/features\/korean-scholarship-search\.md\)/);
|
||||
assert.match(install, /--skill korean-scholarship-search/);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /장학금 검색 및 조회/);
|
||||
assert.match(doc, /kosaf\.go\.kr/);
|
||||
assert.match(doc, /\*\.ac\.kr/);
|
||||
assert.match(doc, /전국 대학교|전국 대학/);
|
||||
assert.match(doc, /공식 공고 우선/);
|
||||
assert.match(doc, /학자금 지원구간/);
|
||||
assert.match(doc, /scholarship_filter\.py/);
|
||||
assert.match(doc, /university_search_plan\.py/);
|
||||
assert.match(doc, /학과/);
|
||||
assert.match(doc, /외부 장학 추천|등록금 감면|생활비 지원/);
|
||||
}
|
||||
|
||||
assert.match(sources, /한국장학재단 학자금 지원구간 산정절차/);
|
||||
assert.match(sources, /한국장학재단 푸른등대 기부장학금/);
|
||||
assert.match(sources, /삼성꿈장학재단/);
|
||||
assert.match(roadmap, /장학금 검색 및 조회 스킬 출시/);
|
||||
assert.ok(
|
||||
!packageJson.workspaces.some((workspace) => workspace.includes("korean-scholarship-search")),
|
||||
"expected no repo workspace to be added for korean-scholarship-search",
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-scholarship-search")), false);
|
||||
});
|
||||
|
||||
test("korean-scholarship-search helper filters normalized records, renders reports, and returns eligibility verdicts", () => {
|
||||
const helperPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "scholarship_filter.py");
|
||||
const plannerPath = path.join(repoRoot, "korean-scholarship-search", "scripts", "university_search_plan.py");
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-scholarship-"));
|
||||
|
||||
try {
|
||||
const inputPath = path.join(tempRoot, "scholarships.json");
|
||||
fs.writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
name: "테스트재단 생활비 장학금",
|
||||
organization: { name: "테스트재단", type: "foundation" },
|
||||
source_url: "https://foundation.example.com/notice/1",
|
||||
apply_url: "https://foundation.example.com/apply/1",
|
||||
amount: { text: "학기당 250만 원", per_semester_krw: 2500000, category: "living" },
|
||||
eligibility: {
|
||||
student_levels: ["undergraduate"],
|
||||
school_kinds: ["university"],
|
||||
school_names: ["서울대학교", "연세대학교"],
|
||||
department_names: ["컴퓨터공학부"],
|
||||
grade_years: [2, 3, 4],
|
||||
gpa_min: 3.2,
|
||||
income_band_min: 0,
|
||||
income_band_max: 6,
|
||||
},
|
||||
deadline: { start: "2026-04-01", end: "2026-04-16" },
|
||||
},
|
||||
{
|
||||
name: "교내 성적우수 장학금",
|
||||
organization: { name: "샘플대학교", type: "school" },
|
||||
source_url: "https://sample.ac.kr/notice/2",
|
||||
apply_url: "https://sample.ac.kr/apply/2",
|
||||
amount: { text: "등록금 전액", category: "tuition" },
|
||||
eligibility: {
|
||||
student_levels: ["undergraduate"],
|
||||
school_kinds: ["university"],
|
||||
school_names: ["샘플대학교"],
|
||||
grade_years: [1],
|
||||
gpa_min: 4.0,
|
||||
income_band_min: 0,
|
||||
income_band_max: 10,
|
||||
},
|
||||
deadline: { start: "2026-05-01", end: "2026-05-20" },
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const helpText = childProcess.execFileSync("python3", [helperPath, "--help"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.match(helpText, /Filter normalized Korean scholarship records/);
|
||||
assert.match(helpText, /\bfilter\b/);
|
||||
assert.match(helpText, /\beligibility\b/);
|
||||
assert.match(helpText, /\breport\b/);
|
||||
|
||||
const plannerHelpText = childProcess.execFileSync("python3", [plannerPath, "--help"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.match(plannerHelpText, /nationwide/i);
|
||||
assert.match(plannerHelpText, /school-name/);
|
||||
|
||||
const filtered = JSON.parse(
|
||||
childProcess.execFileSync(
|
||||
"python3",
|
||||
[
|
||||
helperPath,
|
||||
"filter",
|
||||
"--input",
|
||||
inputPath,
|
||||
"--org-type",
|
||||
"foundation",
|
||||
"--student-level",
|
||||
"undergraduate",
|
||||
"--department-name",
|
||||
"컴퓨터공학부",
|
||||
"--income-band",
|
||||
"4",
|
||||
"--min-amount",
|
||||
"2000000",
|
||||
"--today",
|
||||
"2026-04-14",
|
||||
"--deadline-within-days",
|
||||
"7",
|
||||
],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(filtered.total, 1);
|
||||
assert.equal(filtered.items[0].name, "테스트재단 생활비 장학금");
|
||||
assert.equal(filtered.items[0]._match.amount_krw, 2500000);
|
||||
assert.equal(filtered.items[0]._match.deadline.status, "open");
|
||||
assert.equal(filtered.items[0]._match.deadline.days_until_end, 2);
|
||||
|
||||
const report = childProcess.execFileSync(
|
||||
"python3",
|
||||
[
|
||||
helperPath,
|
||||
"report",
|
||||
"--input",
|
||||
inputPath,
|
||||
"--today",
|
||||
"2026-04-14",
|
||||
"--only-open-now",
|
||||
],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.match(report, /# 장학금 검색 및 조회 리포트/);
|
||||
assert.match(report, /## 지금 지원 가능/);
|
||||
assert.match(report, /테스트재단 생활비 장학금/);
|
||||
assert.match(report, /D-2/);
|
||||
|
||||
const plannerPayload = JSON.parse(
|
||||
childProcess.execFileSync(
|
||||
"python3",
|
||||
[
|
||||
plannerPath,
|
||||
"--school-name",
|
||||
"부산대학교",
|
||||
"--department",
|
||||
"컴퓨터공학과",
|
||||
"--year",
|
||||
"2026",
|
||||
],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
assert.equal(plannerPayload.scope, "school");
|
||||
assert.equal(plannerPayload.school_name, "부산대학교");
|
||||
assert.match(plannerPayload.search_queries.join("\n"), /컴퓨터공학과/);
|
||||
|
||||
const nationwidePayload = JSON.parse(
|
||||
childProcess.execFileSync(
|
||||
"python3",
|
||||
[plannerPath, "--nationwide", "--year", "2026"],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
assert.equal(nationwidePayload.scope, "nationwide-universities");
|
||||
assert.match(nationwidePayload.search_queries.join("\n"), /site:\*\.ac\.kr 2026 장학 공고/);
|
||||
|
||||
const eligibility = JSON.parse(
|
||||
childProcess.execFileSync(
|
||||
"python3",
|
||||
[
|
||||
helperPath,
|
||||
"eligibility",
|
||||
"--input",
|
||||
inputPath,
|
||||
"--school-name",
|
||||
"서울대학교",
|
||||
"--student-level",
|
||||
"undergraduate",
|
||||
"--grade-year",
|
||||
"2",
|
||||
"--gpa",
|
||||
"3.5",
|
||||
"--income-band",
|
||||
"4",
|
||||
],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(eligibility.total, 2);
|
||||
assert.equal(eligibility.results[0].status, "eligible");
|
||||
assert.equal(eligibility.results[1].status, "not_eligible");
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
|
||||
const featureDoc = read(path.join("docs", "features", "real-estate-search.md"));
|
||||
const skill = read(path.join("real-estate-search", "SKILL.md"));
|
||||
|
|
@ -2212,3 +2574,74 @@ test("docs/setup.md and k-skill-setup document hosted school lunch proxy flow",
|
|||
"client secrets example must not encourage KEDU_INFO_KEY (proxy server only)",
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the hola-poke-yeoksam skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "hola-poke-yeoksam.md");
|
||||
const skillPath = path.join(repoRoot, "hola-poke-yeoksam", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hola-poke-yeoksam.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected hola-poke-yeoksam/SKILL.md to exist");
|
||||
|
||||
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
|
||||
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
|
||||
|
||||
assert.match(readme, /\| 올라포케 역삼 포케 \|/);
|
||||
assert.match(readme, /\[올라포케 역삼 포케 가이드\]\(docs\/features\/hola-poke-yeoksam\.md\)/);
|
||||
assert.match(install, /--skill hola-poke-yeoksam/);
|
||||
assert.match(sources, /mnspkm\/hola-poke-yeoksam-skill/);
|
||||
assert.match(roadmap, /올라포케 역삼 포케 스킬 출시/);
|
||||
});
|
||||
|
||||
test("hola-poke-yeoksam docs pin the verified remote MCP contract snapshot and phone-only event flow", () => {
|
||||
const fixture = readJson(path.join("scripts", "fixtures", "hola-poke-yeoksam-contract-smoke.json"));
|
||||
const skill = read(path.join("hola-poke-yeoksam", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "hola-poke-yeoksam.md"));
|
||||
const snapshotLabels = [
|
||||
["initialize 결과", "initialize", "initialize snapshot"],
|
||||
["tools/list 결과", "tools_list", "tools/list snapshot"],
|
||||
["get_menu 구조 예시", "get_menu", "get_menu snapshot"],
|
||||
["get_shop_info 구조 예시", "get_shop_info", "get_shop_info snapshot"],
|
||||
["enter_event(phone='010-12') 예시", "enter_event_invalid_phone", "invalid-phone snapshot"],
|
||||
["enter_event 성공 응답 필수 필드", "enter_event_success_contract", "success-contract snapshot"],
|
||||
];
|
||||
|
||||
assert.match(skill, /^name: hola-poke-yeoksam$/m);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /올라포케 역삼점/);
|
||||
assert.match(doc, /get_menu/);
|
||||
assert.match(doc, /get_shop_info/);
|
||||
assert.match(doc, /enter_event/);
|
||||
assert.match(doc, /이름(?:·|\/)?이메일.*받지 않/);
|
||||
assert.match(doc, /already_entered_today/);
|
||||
assert.match(doc, /message.*글자 그대로/);
|
||||
assert.match(doc, /주문\/결제\/배달앱 자동화는 하지 않/);
|
||||
assert.match(doc, /성공 경로는.*(?:fixture|스냅샷|recorded)/i);
|
||||
assert.match(doc, /라이브 스모크.*invalid-phone|invalid-phone.*라이브 스모크/i);
|
||||
assert.match(doc, /01012345678|010-1234-5678/);
|
||||
assert.match(doc, /hola-poke-yeoksam-skill\.onrender\.com\/mcp/);
|
||||
|
||||
for (const [label, key, message] of snapshotLabels) {
|
||||
assert.equal(
|
||||
findJsonFenceTextAfterLabel(doc, label),
|
||||
JSON.stringify(fixture[key], null, 2),
|
||||
`${message} must stay byte-aligned with the checked-in fixture`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
fixture.tools_list.tools.map((tool) => tool.name),
|
||||
["get_menu", "get_shop_info", "enter_event"],
|
||||
"tools/list fixture must pin the expected remote tool roster",
|
||||
);
|
||||
assert.equal(fixture.get_shop_info.group_order_url, "");
|
||||
assert.match(fixture.get_shop_info.group_order_note, /단체주문|네이버페이/);
|
||||
assert.deepEqual(fixture.enter_event_success_contract.required_fields, ["message", "code", "next_action"]);
|
||||
assert.equal(fixture.enter_event_invalid_phone.error, "phone_format");
|
||||
assert.match(fixture.enter_event_invalid_phone.message, /01012345678|010-1234-5678/);
|
||||
});
|
||||
|
|
|
|||
273
scripts/test_kakaotalk_mac.py
Normal file
273
scripts/test_kakaotalk_mac.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import scripts.kakaotalk_mac as kakaotalk_mac
|
||||
|
||||
|
||||
def sha512_hex(value: int) -> str:
|
||||
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def make_resolved_auth(
|
||||
*,
|
||||
user_id: int = 123,
|
||||
uuid: str = "uuid",
|
||||
database_path: Path | None = None,
|
||||
database_name: str = "db-name",
|
||||
key: str = "super-secret",
|
||||
source: str = "cache",
|
||||
) -> kakaotalk_mac.ResolvedAuth:
|
||||
return kakaotalk_mac.ResolvedAuth(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
database_path=database_path or Path("/tmp/kakaotalk.db"),
|
||||
database_name=database_name,
|
||||
key=key,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
class KakaoTalkMacHelperTests(unittest.TestCase):
|
||||
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
|
||||
active_hash = sha512_hex(123456)
|
||||
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AlertKakaoIDsList</key>
|
||||
<array>
|
||||
<integer>111</integer>
|
||||
<integer>222</integer>
|
||||
</array>
|
||||
<key>userId</key>
|
||||
<integer>333</integer>
|
||||
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
|
||||
|
||||
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
|
||||
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
|
||||
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
|
||||
|
||||
def test_discover_database_files_filters_hex_names(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
root = Path(tempdir)
|
||||
expected = [
|
||||
root / ("a" * 78),
|
||||
root / ("b" * 78 + ".db"),
|
||||
]
|
||||
for path in expected:
|
||||
path.write_text("", encoding="utf-8")
|
||||
(root / ("c" * 40)).write_text("", encoding="utf-8")
|
||||
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
|
||||
|
||||
discovered = kakaotalk_mac.discover_database_files(root)
|
||||
|
||||
self.assertEqual(discovered, expected)
|
||||
|
||||
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
|
||||
target_user_id = 123456
|
||||
recovered = kakaotalk_mac.recover_user_id_from_sha512(
|
||||
sha512_hex(target_user_id),
|
||||
max_user_id=200000,
|
||||
workers=1,
|
||||
chunk_size=5000,
|
||||
)
|
||||
|
||||
self.assertEqual(recovered, target_user_id)
|
||||
|
||||
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
|
||||
target_user_id = 654321
|
||||
active_hash = sha512_hex(target_user_id)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
verification_calls: list[int] = []
|
||||
|
||||
state = kakaotalk_mac.DetectionState(
|
||||
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
|
||||
candidate_user_ids=[111, 222],
|
||||
active_account_hash=active_hash,
|
||||
database_files=[database_path],
|
||||
)
|
||||
|
||||
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
|
||||
verification_calls.append(candidate.user_id)
|
||||
return candidate.user_id == target_user_id
|
||||
|
||||
resolved = kakaotalk_mac.resolve_auth_state(
|
||||
state,
|
||||
verify_access=verify,
|
||||
cache_path=cache_path,
|
||||
max_user_id=700000,
|
||||
workers=1,
|
||||
chunk_size=10000,
|
||||
)
|
||||
|
||||
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(verification_calls, [111, 222, target_user_id])
|
||||
self.assertEqual(resolved.user_id, target_user_id)
|
||||
self.assertEqual(resolved.database_path, database_path)
|
||||
self.assertEqual(cache_payload["user_id"], target_user_id)
|
||||
self.assertEqual(cache_payload["database_path"], str(database_path))
|
||||
|
||||
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
|
||||
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
|
||||
|
||||
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
|
||||
):
|
||||
cached = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(cached, resolved)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once()
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override="override-uuid",
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with("override-uuid")
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_render_auth_text_redacts_key_material(self) -> None:
|
||||
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
|
||||
|
||||
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
|
||||
|
||||
self.assertNotIn("super-secret-key", rendered)
|
||||
self.assertNotIn("--key", rendered)
|
||||
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
|
||||
|
||||
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
|
||||
auth = make_resolved_auth()
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
|
||||
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
|
||||
|
||||
def test_build_parser_only_exposes_read_only_commands(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
subcommands = parser._subparsers._group_actions[0].choices
|
||||
|
||||
self.assertEqual(sorted(subcommands), ["auth", "chats", "messages", "schema", "search"])
|
||||
self.assertNotIn("query", subcommands)
|
||||
|
||||
def test_build_parser_rejects_negative_max_user_id(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--max-user-id", "-1"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be non-negative", stderr.getvalue())
|
||||
|
||||
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--chunk-size", "0"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be positive", stderr.getvalue())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -38,6 +38,7 @@ done < <(
|
|||
find "$root" -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name .git \
|
||||
! -name .github \
|
||||
! -name .codex \
|
||||
! -name .claude \
|
||||
! -name .omx \
|
||||
! -name .ouroboros \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue