Merge branch 'dev' into feature/k-schoollunch-menu

This commit is contained in:
hon2be 2026-04-10 13:53:51 +09:00 committed by GitHub
commit 1e89bace8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 9733 additions and 145 deletions

View file

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

View file

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

View file

@ -0,0 +1,5 @@
---
"hipass-receipt": minor
---
Publish the first logged-in-session helper package and skill docs for Hi-Pass receipt workflows.

View file

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

View file

@ -0,0 +1,5 @@
---
"market-kurly-search": minor
---
Publish the first reusable Market Kurly product search package and skill docs for unauthenticated price lookups.

View file

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

View file

@ -24,30 +24,47 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| KTX 예매 | KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 카카오톡 Mac CLI | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 지하철 분실물 조회 | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 한국 날씨 조회 | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
| 사용자 위치 미세먼지 조회 | 현재 위치 또는 지역 기준 PM10/PM2.5 미세먼지 조회 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
| 한국 법령 검색 | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 생활쓰레기 배출정보 조회 | 시군구 기준 생활쓰레기·음식물·재활용 배출요일·시간·장소·관리부서 확인 | 불필요 | [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md) |
| 학교 급식 식단 조회 | 교육청·학교명으로 NEIS 학교 검색·급식 식단 조회 | 불필요 | [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md) |
| 의약품 안전 체크 | 식약처 e약은요·안전상비의약품 정보를 인터뷰-first 흐름으로 조회 | 필요 | [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md) |
| 식품 안전 체크 | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 조회 | 필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-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) |
| 근처 블루리본 맛집 | 현재 위치 기준 근처 블루리본 선정 맛집 조회 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 불필요 | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 번개장터 검색 | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
| 중고차 가격 조회 | 중고차 인수가/월 렌트료 비교 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
| 한국어 맞춤법 검사 | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
| 한국어 글자 수 세기 | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
>
> **블루리본 측이 `www.bluer.co.kr` 에 자동화 접근 전면 차단을 적용해 스킬이 더 이상 동작하지 않습니다.**
>
> - 브라우저·`curl`·Playwright·TLS impersonation 등 가능한 우회를 모두 검증했지만 nginx 단에서 403이 반환되며, 같은 가구 공인 IP로도 특정 장비만 차단되는 상황이 관측되었습니다.
> - 유료 회원권 보유자도 접근이 막히는 사례가 확인되었습니다. 복구 여부와 일정은 블루리본 측 정책에 전적으로 달려 있어 이 레포에서 대응할 수 있는 범위를 벗어났습니다.
> - 해당 스킬 디렉토리(`blue-ribbon-nearby/`)와 관련 프록시 라우트는 히스토리 보존을 위해 당분간 남겨두지만, **새 프로젝트에서는 해당 스킬을 사용하지 마세요.** 차단이 해제되는 날이 오면 이 안내를 제거하고 재검증하겠습니다.
## 처음 시작하는 순서
@ -75,29 +92,39 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [KTX 예매](docs/features/ktx-booking.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [생활쓰레기 배출정보 조회 가이드](docs/features/household-waste-info.md)
- [학교 급식 식단 조회 가이드](docs/features/k-schoollunch-menu.md)
- [의약품 안전 체크 가이드](docs/features/mfds-drug-safety.md)
- [식품 안전 체크 가이드](docs/features/mfds-food-safety.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [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/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [번개장터 검색 가이드](docs/features/bunjang-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [릴리스/배포 가이드](docs/releasing.md)
설치 기본 흐름은 "전체 스킬 설치 → `k-skill-setup` 실행 → 개별 기능 사용" 입니다.

164
bunjang-search/SKILL.md Normal file
View file

@ -0,0 +1,164 @@
---
name: bunjang-search
description: 번개장터 검색, 상세조회, 찜, 채팅, 대량 수집, AI TOON export를 bunjang-cli로 안내한다.
license: MIT
metadata:
category: marketplace
locale: ko-KR
phase: v1
---
# Bunjang Search
## What this skill does
upstream [`bunjang-cli`](https://www.npmjs.com/package/bunjang-cli) / [`pinion05/bunjangcli`](https://github.com/pinion05/bunjangcli) 를 사용해 번개장터에서 아래 흐름을 처리한다.
- 상품 검색
- 상품 상세조회
- 선택적 찜/채팅
- 다페이지 대량 수집
- AI 분석용 TOON chunk export
## Core policy
- 기본 경로는 **항상 CLI first** 다.
- 기본 명령은 `npx --yes bunjang-cli ...` 형식을 쓴다.
- `auth login` 은 headful 브라우저 + **TTY / interactive 터미널**이 필요하다.
- 로그인 전에는 검색/상세조회/대량 수집 위주로 답하고, `favorite` / `chat` / `purchase` 는 **선택적 로그인 플로우**로만 안내한다.
- 대량 수집은 `--start-page`, `--pages`, `--max-items`, `--with-detail`, `--output` 조합을 우선 쓴다.
- AI 분석용 export 는 `--ai --output <directory>``.toon` chunk 를 만든다.
- 찜/채팅은 명시적으로 요청받지 않으면 실행하지 않는다.
## When to use
- "번개장터에서 아이폰 검색해줘"
- "번장에서 이 상품 상세 봐줘"
- "여러 페이지 모아서 JSON으로 저장해줘"
- "AI 평가용으로 번개장터 결과를 chunk 로 만들어줘"
## When not to use
- 계정 로그인 없이 바로 찜/채팅을 강행해야 하는 경우
- 구매 확정/결제 자동화를 기대하는 경우
- 번개장터 외 다른 중고거래 플랫폼을 동시에 다뤄야 하는 경우
## Quick smoke test
```bash
npx --yes bunjang-cli --help
npx --yes bunjang-cli --json auth status
npx --yes bunjang-cli --json search "아이폰" --max-items 3 --sort date
npx --yes bunjang-cli --json item get 354957625
```
## Login flow
```bash
npx --yes bunjang-cli auth login
npx --yes bunjang-cli auth logout
npx --yes bunjang-cli --json auth status
```
- `auth login` 은 브라우저에서 로그인한 뒤 **터미널로 돌아와 Enter 를 눌러야** 완료된다.
- 그래서 비-TTY 실행 대신 interactive 세션에서만 진행한다.
## Search flow
```bash
npx --yes bunjang-cli search "아이폰"
npx --yes bunjang-cli search "아이폰" --price-min 500000 --price-max 1200000
npx --yes bunjang-cli search "아이폰" --sort date
npx --yes bunjang-cli --json search "아이폰" --max-items 5
```
검색 결과는 광고/매입글/악세서리 노이즈가 섞이고, search summary 의 `location` 이 noisy 하거나 `description` / `status` 가 비어 있을 수 있다. 그래서 **검색 단계는 제목/가격 중심 1차 triage** 로만 쓴다.
- 기기명/용량 키워드 일치 여부
- 가격대 범위
- 판매 링크/썸네일 중복 여부
`description`, `status`, 깔끔한 `location` 이 필요하면 **반드시 `item get` 또는 `--with-detail` 이후** 에만 판단한다.
## Detail flow
```bash
npx --yes bunjang-cli item get 354957625
npx --yes bunjang-cli --json item get 354957625
npx --yes bunjang-cli --json item list --ids 354957625,354801707
```
상세조회에서는 아래 필드를 먼저 읽는다.
- `price`
- `description`
- `location`
- `category`
- `status`
- `sellerName`
- `sellerItemCount`
- `sellerFollowerCount`
- `sellerReviewCount`
- `favoriteCount`
- `transportUsed`
## Bulk collection
```bash
npx --yes bunjang-cli search "아이폰" \
--start-page 1 \
--pages 5 \
--max-items 50 \
--sort date \
--with-detail \
--output artifacts/bunjang-iphone.json
```
검증할 때는 export 파일 생성 여부와 top-level `items[]` 안의 `summary` / `detail` / optional `error` 구조, 그리고 각 item 의 `sourcePage` 또는 `summary.raw.page` 를 같이 확인한다.
## AI export
```bash
npx --yes bunjang-cli search "아이폰" \
--start-page 1 \
--pages 5 \
--max-items 50 \
--with-detail \
--ai \
--output artifacts/bunjang-iphone-ai
```
- `--ai` 에서는 `--output`**파일이 아니라 디렉토리** 여야 한다.
- 결과는 `items-1.toon` 형태 chunk 로 저장된다.
- AI 평가용으로 여러 서브에이전트에 분산 읽기시키기 좋다.
## Optional favorite/chat flow
로그인된 interactive 세션에서만 아래 액션을 진행한다.
```bash
npx --yes bunjang-cli --json favorite list
npx --yes bunjang-cli --json favorite add 354957625
npx --yes bunjang-cli --json favorite remove 354957625
npx --yes bunjang-cli --json chat list
npx --yes bunjang-cli --json chat start 354957625 --message "안녕하세요"
npx --yes bunjang-cli --json chat send 84191651 --message "상품 상태 괜찮을까요?"
```
- 찜/채팅은 **로그인이 필요한 선택적 기능**이다.
- 검증 목적이면 `favorite list` 로 세션을 먼저 확인하고, 같은 상품에 대해 `favorite add` / `favorite remove` 를 왕복 실행한다.
- `chat start` 는 상품 페이지에서 새 대화를 열 때, `chat send` 는 기존 thread 에 메시지를 보낼 때 쓴다.
## Recommended response format
1. 검색어가 넓으면 예산/모델/지역을 먼저 좁힌다.
2. 검색 결과 상위 3~5개는 제목/가격 중심 1차 요약만 한다.
3. `description` / `status` / `location` 판단이 필요하면 `item get` 또는 `--with-detail` 로 상세를 먼저 읽는다.
4. 로그인 액션이 필요하면 "지금은 로그인 세션이 없으니 interactive TTY 에서 `auth login` 후 다시 진행" 이라고 분명히 말한다.
5. 대량 분석이면 JSON export 또는 TOON chunk 생성 경로를 제안한다.
## Done when
- 검색/상세조회/대량 수집/AI export 중 필요한 경로가 안내되었다.
- 찜/채팅은 로그인 필요성과 선택적 성격이 명확히 고지되었다.
- 자동 구매/결제는 범위 밖이라고 분명히 말했다.

View file

@ -0,0 +1,152 @@
# 번개장터 검색 가이드
## 이 기능으로 할 수 있는 일
upstream [`bunjang-cli`](https://www.npmjs.com/package/bunjang-cli) / [`pinion05/bunjangcli`](https://github.com/pinion05/bunjangcli) 를 사용해 번개장터 **검색, 상세조회, 선택적 찜/채팅, 대량 수집, AI TOON export** 를 처리한다.
- `search` 로 검색
- `item get` / `item list` 로 상세조회
- `favorite add` / `favorite remove` / `favorite list` 로 선택적 찜 관리
- `chat list` / `chat start` / `chat send` 로 선택적 채팅
- `--start-page`, `--pages`, `--max-items`, `--with-detail`, `--output` 으로 대량 수집
- `--ai --output <directory>` 로 TOON chunk 저장
## 가장 중요한 규칙
이 기능은 upstream 원본을 그대로 쓴다.
`k-skill` 안에 번개장터 수집기를 새로 넣지 않고, **CLI first** 문서/스킬만 유지한다.
즉 기본 경로는 아래다.
1. `npx --yes bunjang-cli ...`
2. 반복 사용이면 `npm install -g bunjang-cli`
## 먼저 필요한 것
- 인터넷 연결
- `node` 22+
- `npx` 또는 `npm`
- 선택적으로 interactive TTY 터미널
2026-04-06 기준 `npm view bunjang-cli version``0.2.1` 이고, README 요구 사항은 Node.js 22+ 다.
## 가장 빠른 시작: npx CLI
```bash
npx --yes bunjang-cli --help
npx --yes bunjang-cli --json auth status
npx --yes bunjang-cli --json search "아이폰" --max-items 3 --sort date
npx --yes bunjang-cli --json item get 354957625
```
반복 사용이면 전역 설치도 가능하다.
```bash
npm install -g bunjang-cli
export NODE_PATH="$(npm root -g)"
bunjang-cli --help
```
## 로그인은 선택적 interactive 플로우
```bash
npx --yes bunjang-cli auth login
npx --yes bunjang-cli auth logout
npx --yes bunjang-cli --json auth status
```
- `auth login` 은 브라우저에서 로그인 후 **터미널로 돌아와 Enter 를 눌러야** 완료된다.
- 즉 **TTY / interactive 세션** 이 아닌 환경에서는 로그인 완료 처리가 멈출 수 있다.
- 그래서 기본 문서는 검색/상세조회/대량 수집을 먼저 안내하고, 찜/채팅은 로그인된 경우에만 선택적으로 진행한다.
## 기본 흐름
### 1. 검색
```bash
npx --yes bunjang-cli search "아이폰"
npx --yes bunjang-cli search "아이폰" --price-min 500000 --price-max 1200000
npx --yes bunjang-cli search "아이폰" --sort date
npx --yes bunjang-cli --json search "아이폰" --max-items 5
```
검색 결과에는 매입글/광고/악세서리 글이 섞이고, live `search` payload 에서는 `location` 이 noisy 하거나 `description` / `status` 가 빠질 수 있다. 그래서 **검색 단계는 제목/가격 중심 1차 triage** 로만 쓰고, 세부 판단은 상세조회 이후로 미룬다.
### 2. 상세조회
```bash
npx --yes bunjang-cli item get 354957625
npx --yes bunjang-cli --json item get 354957625
npx --yes bunjang-cli --json item list --ids 354957625,354801707
```
상세에서는 `price`, `description`, `location`, `category`, `status`, `sellerName`, `sellerReviewCount`, `favoriteCount`, `transportUsed` 를 우선 본다. 즉 `description` / `status` / 믿을 만한 `location` 이 필요하면 **반드시 `item get` 또는 `--with-detail` 이후** 에만 판정한다.
### 3. 대량 수집
```bash
npx --yes bunjang-cli search "아이폰" \
--start-page 1 \
--pages 5 \
--max-items 50 \
--sort date \
--with-detail \
--output artifacts/bunjang-iphone.json
```
export 검증 시에는 결과 파일 존재 여부와 top-level `items[]` 안의 `summary` / `detail` / optional `error` 구조, item 별 `sourcePage` 또는 `summary.raw.page` 를 함께 확인한다.
### 4. AI TOON chunk
```bash
npx --yes bunjang-cli search "아이폰" \
--start-page 1 \
--pages 5 \
--max-items 50 \
--with-detail \
--ai \
--output artifacts/bunjang-iphone-ai
```
- `--ai` 에서는 `--output`**디렉토리** 여야 한다.
- 결과는 `items-1.toon` 같은 chunk 로 나뉜다.
- 대량 후보를 여러 에이전트에게 분산 평가시키기 좋다.
### 5. 찜/채팅은 로그인 후에만
```bash
npx --yes bunjang-cli --json favorite list
npx --yes bunjang-cli --json favorite add 354957625
npx --yes bunjang-cli --json favorite remove 354957625
npx --yes bunjang-cli --json chat list
npx --yes bunjang-cli --json chat start 354957625 --message "안녕하세요"
npx --yes bunjang-cli --json chat send 84191651 --message "상품 상태 괜찮을까요?"
```
- `favorite``chat` 은 **로그인이 필요한 선택적 기능**이다.
- 기본 검색/분석 요청이라면 실행하지 않고 명령만 안내한다.
- 검증이 필요하면 `favorite list` 로 세션을 먼저 확인한 뒤 add/remove 를 왕복 실행한다.
## 라이브 확인 메모
2026-04-06 기준 아래 흐름을 실제로 실행해 응답을 확인했다.
- `npx --yes bunjang-cli --help` → top-level commands (`auth`, `search`, `item`, `chat`, `favorite`, `purchase`) 확인
- `npx --yes bunjang-cli --json auth status``authenticated: false`, `headfulLoginRequired: true` 확인
- `npx --yes bunjang-cli --json search "아이폰" --max-items 1``items[0].id = 354957625`, `transportUsed = browser` 확인
- `npx --yes bunjang-cli --json item get 354957625``description`, `location`, `sellerReviewCount`, `favoriteCount`, `transportUsed = api` 확인
같은 날짜에 `search` summary 는 title/price 중심 triage 용으로만 안전했고, `status` / `description` 은 summary 에서 안정적으로 오지 않았다. 그래서 문서에서는 상세 필드 의존 판단을 `item get` / `--with-detail` 뒤로 고정한다.
같은 날짜에 `favorite list` 는 로그인 브라우저를 띄운 뒤 터미널 Enter 를 기다렸다. 그래서 문서에서는 찜/채팅을 **로그인 후 선택적으로만 실행** 하도록 고정한다.
## 제한사항
- 로그인 없는 환경에서는 찜/채팅/구매 흐름을 바로 검증하기 어렵다.
- 검색 결과에는 매입글/광고/악세서리 노이즈가 섞일 수 있다.
- DOM/API 변경에 따라 browser transport 동작이 깨질 수 있다.
- 구매 자동 확정/결제는 다루지 않는다.
## 참고 링크
- npm package: `https://www.npmjs.com/package/bunjang-cli`
- upstream repo: `https://github.com/pinion05/bunjangcli`

View file

@ -0,0 +1,108 @@
# 하이패스 영수증 발급 가이드
## 이 기능으로 할 수 있는 일
- 공식 하이패스 홈페이지에서 로그인된 Chrome 세션 재사용
- 사용내역 조회
- 특정 거래의 영수증 팝업/출력 화면 진입
- 세션 만료 감지 후 재로그인 안내
## 먼저 알아둘 점
- 이 기능은 **로그인된 브라우저 세션에서만 동작**한다.
- 하이패스 ID/PW/인증코드 입력을 자동화하지 않는다.
- 공개 페이지 기준으로 세션 타이머는 **20분(`session_time=1200`)** 이고, 세션 연장/종료는 `/comm/sessionCheck.do`, `/comm//sessionout.do` 경로를 사용한다.
- 보호 페이지는 `mgs_type 11/12``/comm/lginpg.do` 이동으로 세션 종료를 알린다.
- 따라서 쿠키 파일만 오래 보관해 재사용하는 완전 자동 로그인 유지 봇은 v1 범위 밖이다.
## 설치
```bash
npm install hipass-receipt
```
배포 패키지에는 CDP 연결용 `playwright-core` 가 함께 들어 있다. 별도 Playwright 브라우저를 내려받지 않고, 사용자가 직접 연 Chrome/Chromium 세션에 붙는다.
이 레포를 clone 한 유지보수자라면 루트에서 `npm install` 로 workspace 패키지까지 함께 설치해도 된다.
## 로그인 브라우저 준비
전용 Chrome 프로필 + CDP 포트로 브라우저를 띄운다.
```bash
hipass-receipt chrome-command --profile-dir "$HOME/.cache/k-skill/hipass-chrome" --debugging-port 9222
```
그 다음 사용자가 직접 `https://www.hipass.co.kr/comm/lginpg.do` 에 로그인한다.
## 사용내역 조회
```bash
hipass-receipt list \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--page-size 30 \
--encrypted-card-number BASE64_OR_SITE_VALUE
```
내부적으로 다음 흐름을 사용한다.
1. `/usepculr/InitUsePculrTabSearch.do` 진입
2. `hpForm` 에 검색조건 주입
3. `/usepculr/UsePculrTabSearchList.do` 로 submit
4. iframe HTML 파싱 후 정규화 JSON 반환
`--encrypted-card-number` 는 기존 `--ecd-no` 별칭과 같다.
## 영수증 팝업 열기
먼저 `list` 결과에서 원하는 행의 `rowIndex` 를 확인한다.
```bash
hipass-receipt receipt \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--row-index 1
```
이 명령은 선택한 행의 `영수증`/`출력` control 을 클릭하고, 팝업이 열리면 URL/title 을 반환한다. 공식 안내 기준으로 사용내역 화면에서는 `영수증선택출력` 또는 `영수증전체출력` 으로 이어진다.
## 세션 만료 처리
다음 신호 중 하나가 보이면 즉시 실패시키고 재로그인을 요구한다.
- `/comm/lginpg.do` redirect
- `mgs_type = 11`
- `mgs_type = 12`
- 권한체크(CommonAuthCheck.jsp) 응답
## 검증 전략
### 자동 검증
- query builder 테스트
- 사용내역 목록 HTML parser 테스트
- 상세/영수증 submit field 보존 테스트
- 로그인/권한체크 페이지 감지 테스트
### 로그인 없이 가능한 smoke
```bash
node packages/hipass-receipt/src/cli.js fixture-demo \
--fixture packages/hipass-receipt/test/fixtures/usage-history-list.html
```
### 로그인 세션이 필요한 최종 확인
- 실제 계정으로 로그인된 전용 Chrome 프로필 준비
- `hipass-receipt list` 실행
- `hipass-receipt receipt --row-index <n>` 실행
- 세션 만료 후 다시 실행해 재로그인 요구 메시지 확인
## 보안 원칙
- 하이패스 비밀번호를 새 env var나 repo 문서에 추가하지 않는다.
- 로그인은 반드시 사용자가 브라우저 안에서 직접 수행한다.
- 이 기능은 조회/영수증 보조까지만 다루며 장기 무인 자동화는 약속하지 않는다.

View file

@ -12,12 +12,16 @@
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래와 같습니다.
현재 기본 엔드포인트는 아래 다섯 가지입니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/opinet/around`
- `GET /v1/opinet/detail`
- `GET /v1/neis/school-search` (나이스 학교기본정보, `KEDU_INFO_KEY`)
@ -33,10 +37,12 @@ client/skill -> k-skill-proxy -> upstream public API
프록시 서버 쪽:
- `AIR_KOREA_OPEN_API_KEY=...`
- `KMA_OPEN_API_KEY=...`
- `SEOUL_OPEN_API_KEY=...`
- `HRFCO_OPEN_API_KEY=...`
- `OPINET_API_KEY=...`
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
- `KRX_API_KEY=...`
- `KSKILL_PROXY_PORT=4020`
## 프로덕션 배포 구조
@ -99,6 +105,14 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한국 날씨 endpoint:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
한강 수위 정보 endpoint:
```bash
@ -140,6 +154,24 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-meal' \
--data-urlencode 'mealDate=20260410'
```
한국 주식 검색 endpoint:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
한국 주식 기본정보 endpoint:
```bash
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'
```
AirKorea passthrough endpoint:
```bash
@ -155,5 +187,7 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/B552584/ArpltnInforInqireSv
## 주의할 점
- upstream key는 프록시 서버에서만 관리합니다.
- 한국 주식 route도 사용자에게 `KRX_API_KEY` 를 배포하지 않습니다.
- client 쪽에는 upstream API key를 배포하지 않습니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 서울 지하철/한국 날씨 예시를 local/self-host URL 로 검증합니다.
- public hosted route rollout 이 끝나기 전에는 한강 수위 route도 local/self-host 또는 배포 확인이 끝난 proxy URL 로 검증합니다.

View file

@ -0,0 +1,70 @@
# 한국 날씨 조회 가이드
## 이 기능으로 할 수 있는 일
- 한국 기상청 단기예보 조회서비스를 proxy 경유로 호출
- `nx` / `ny` 격자 또는 `lat` / `lon` 기준 단기예보 확인
- 개인 OpenAPI key 없이 `TMP`, `SKY`, `PTY`, `POP` 같은 핵심 날씨 category 요약
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- [보안/시크릿 정책](../security-and-secrets.md) 확인
- self-host 또는 배포 확인이 끝난 proxy base URL: `KSKILL_PROXY_BASE_URL`
## 필요한 환경변수
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자가 공공데이터포털 기상청 단기예보 API key를 직접 발급할 필요는 없다. 대신 `KSKILL_PROXY_BASE_URL``/v1/korea-weather/forecast` route가 실제로 배포된 proxy 를 가리켜야 한다. upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다.
## 입력값
- 격자 좌표 `nx`, `ny`
- 또는 위도/경도 `lat`, `lon`
- 선택 사항: `baseDate`, `baseTime`, `pageNo`, `numOfRows`
## 기본 흐름
1. `KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
2. `/v1/korea-weather/forecast` 로 한국 기상청 단기예보를 조회한다.
3. `baseDate` / `baseTime` 을 생략하면 proxy 가 KST 기준 최신 발표 시각을 자동으로 선택한다.
4. 응답의 `item[]` 에서 `TMP`, `SKY`, `PTY`, `POP`, `PCP`, `SNO`, `REH`, `WSD` 를 우선 요약한다.
## 예시
위도/경도 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
격자 좌표 기준:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'nx=60' \
--data-urlencode 'ny=127' \
--data-urlencode 'baseDate=20260405' \
--data-urlencode 'baseTime=0500'
```
## Category 메모
- `TMP`: 기온(℃)
- `SKY`: 하늘상태
- `PTY`: 강수형태
- `POP`: 강수확률(%)
- `PCP`: 강수량
- `SNO`: 적설
- `REH`: 습도(%)
- `WSD`: 풍속(m/s)
## 주의할 점
- 단기예보는 5km 격자 기반이라 행정구역 경계와 완전히 일치하지 않을 수 있다.
- 발표 시각 직후에는 최신 `baseTime` 이 아직 준비되지 않았을 수 있다. proxy 는 보수적으로 직전 발표 시각을 선택한다.
- public hosted route rollout 이 끝나기 전까지는 `KSKILL_PROXY_BASE_URL` 을 반드시 명시한다.
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 본다.

View file

@ -0,0 +1,120 @@
# 한국어 글자 수 세기 가이드
## 이 기능으로 할 수 있는 일
- 한국어 텍스트의 글자 수를 `Intl.Segmenter` 기반 grapheme contract로 계산
- 줄 수를 `CRLF`/`LF`/`CR`/`U+2028`/`U+2029` 기준으로 계산
- UTF-8 byte 수를 실제 인코딩 길이로 계산
- `neis` 호환 byte 프로필로 다시 계산
- 텍스트 직접 입력, 파일 입력, stdin 파이프 입력 처리
## 왜 별도 스킬이 필요한가
- 자기소개서/지원서 폼은 1자 차이도 민감하다.
- LLM이 글자 수를 대충 추정하면 같은 입력에서 결과가 흔들릴 수 있다.
- 이 저장소의 목적은 **계산 계약을 고정한 helper** 를 제공하는 것이다.
## 기본 계약
### `default` profile
- characters: `Intl.Segmenter` 기반 Unicode extended grapheme clusters
- bytes: `Buffer.byteLength(text, "utf8")`
- lines:
- 빈 문자열은 `0`
- 비어 있지 않으면 줄바꿈 시퀀스 수 + `1`
- `CRLF` 는 줄바꿈 `1회`
이 계약은 입력을 임의로 trim 하거나 정규화하지 않는다. 공백, 개행, emoji, 조합형 자모도 원문 그대로 센다.
### `neis` profile
- characters: `default` 와 동일
- lines: `default` 와 동일
- bytes:
- 한글 grapheme `3B`
- ASCII grapheme `1B`
- Enter/줄바꿈 시퀀스 `2B`
- 나머지는 UTF-8 byte fallback
`neis`**byte 계산만** 한국 교육행정 호환 규칙으로 바꾼 compatibility profile이다.
## CLI 사용 예시
### 기본 JSON 출력
```bash
node scripts/korean_character_count.js --text "가나다"
```
예상 출력:
```json
{
"profile": "default",
"contract": {
"characters": "Unicode extended grapheme clusters via Intl.Segmenter",
"bytes": "Actual UTF-8 encoded byte length",
"lines": "Empty string => 0 lines; otherwise count CRLF, LF, CR, U+2028, U+2029 as one line break each and add 1"
},
"counts": {
"characters": 3,
"characters_without_whitespace": 3,
"code_points": 3,
"utf16_code_units": 3,
"lines": 1,
"bytes": 9,
"bytes_utf8": 9,
"bytes_neis": 9
}
}
```
### 줄바꿈 + 호환 byte 프로필
```bash
node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profile neis --format text
```
예상 출력:
```text
profile: neis
characters: 9
lines: 2
bytes: 23
```
### 파일 입력
```bash
node scripts/korean_character_count.js --file ./essay.txt
```
### stdin 입력
```bash
cat essay.txt | node scripts/korean_character_count.js --profile neis
```
## 구현 메모
- `Intl.Segmenter``CRLF` 를 grapheme cluster 하나로 다뤄 Windows 줄바꿈에서도 과한 이중 카운트를 피한다.
- UTF-8 byte 수는 문자 종류를 암산하지 않고 실제 인코딩 길이를 쓴다.
- `neis` 는 byte 규칙만 바꾸는 compatibility layer라서, characters/lines 계약은 기본값과 동일하다.
- 제출처가 별도 계약을 문서로 명시하지 않으면 `default` 를 기본값으로 쓴다.
## 라이브 검증 메모
2026-04-08 기준으로 아래 로컬 smoke run을 확인했다.
- `node scripts/korean_character_count.js --text "가\r\n나"``characters=3`, `lines=2`, `bytes=8`을 반환한다.
- `node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profile neis --format text``bytes=23`을 반환한다.
- `cat essay.txt | node scripts/korean_character_count.js` 경로도 JSON 출력이 동작한다.
## 참고 표면
- Unicode UAX #29: https://www.unicode.org/reports/tr29/
- WHATWG Encoding Standard: https://encoding.spec.whatwg.org/
- Node `Buffer.byteLength`: https://nodejs.org/api/buffer.html
- 경기도교육청 학교생활기록부 기재요령 PDF: https://www.goe.go.kr/resource/old/BBSMSTR_000000030136/BBS_202302211104253520.pdf

View file

@ -0,0 +1,106 @@
# 한국 특허 정보 검색 가이드
## 이 기능으로 할 수 있는 일
- KIPRIS Plus 공식 Open API로 한국 특허/실용신안 키워드 검색
- 출원번호 기준 서지 상세 조회
- 출원번호, 발명의명칭, 출원인, IPC, 초록, 공개/공고/등록 메타데이터 정리
- JSON 형태로 후속 자동화에 넘기기
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- KIPRIS Plus API key
- helper 환경변수: `KIPRIS_PLUS_API_KEY`
- 실제 요청 쿼리 파라미터: `ServiceKey`
- 설치된 `korean-patent-search` skill 안에 `scripts/patent_search.py` helper 포함
공공데이터포털 안내 기준으로 이 API는 개발계정은 자동승인, 운영계정은 심의승인 대상이다.
## 공식 표면
- KIPRIS Plus 포털: `https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15058788/openapi.do`
- helper 기본 endpoint: `https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getWordSearch`
- 상세 endpoint: `https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getBibliographyDetailInfoSearch`
## 기본 흐름
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값이어도 helper가 한 번 정규화한 뒤 요청한다.
2. 키워드 검색이면 `getWordSearch` 를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` 를 호출한다.
4. XML `response/header/body/items/item` 구조를 파싱한다.
5. JSON으로 출력한다.
## CLI 예시
### 키워드 검색
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
```
### 연도 + 페이지 지정
```bash
python3 scripts/patent_search.py --query "배터리" --year 2024 --page-no 1 --num-rows 5
```
### 출원번호 상세 조회
```bash
python3 scripts/patent_search.py --application-number 1020240001234
```
## 응답 예시 포맷
```json
{
"query": "배터리",
"page_no": 1,
"num_of_rows": 5,
"total_count": 24,
"items": [
{
"index_no": 1,
"application_number": "1020240001234",
"invention_title": "이차 전지 배터리 팩",
"register_status": "공개",
"application_date": "2024/01/02 00:00:00",
"open_number": "1020250005678",
"open_date": "2025/07/09 00:00:00",
"publication_number": "1020250005678",
"publication_date": "2025/07/09 00:00:00",
"ipc_number": "H01M 10/00",
"applicant_name": "주식회사 오픈에이아이코리아",
"abstract_text": "배터리 수명 향상을 위한 열 관리 구조."
}
]
}
```
## 구현 메모
- `getWordSearch` 요청 파라미터 핵심은 `word`, `year`, `patent`, `utility`, `ServiceKey` 이다.
- `getBibliographyDetailInfoSearch``applicationNumber`, `ServiceKey` 를 사용한다.
- helper는 표준 라이브러리 `urllib` + `xml.etree.ElementTree` 만 사용한다.
- 에러 응답의 `resultCode` / `resultMsg` 는 감추지 않고 그대로 surfaced 한다.
## Done when
- `python3 scripts/patent_search.py --query "배터리"` 형태의 검색 명령이 준비된다.
- `python3 scripts/patent_search.py --application-number 1020240001234` 형태의 상세 조회 명령이 준비된다.
- 키가 없거나 잘못됐을 때 `KIPRIS_PLUS_API_KEY` / `ServiceKey` 안내가 분명하다.
- 출력 JSON에 출원번호와 발명의명칭이 포함된다.
## 검증 메모
2026-04-05 기준 로컬에서 아래 항목을 실제 실행해 helper 동작을 검증했다.
- `python3 scripts/patent_search.py --help`
- dummy `ServiceKey` 로 KIPRIS Plus endpoint 호출 시 인증 오류가 명시적으로 surfaced 되는지 확인
- 단위 테스트로 `getWordSearch` / `getBibliographyDetailInfoSearch` XML 파싱 회귀 검증
유효한 `KIPRIS_PLUS_API_KEY` 가 준비된 환경에서는 바로 실검색으로 이어서 검증할 수 있다.

View file

@ -0,0 +1,81 @@
# 한국 주식 정보 조회 가이드
## 이 기능으로 할 수 있는 일
- KRX 상장 종목 검색 (`/v1/korean-stock/search`)
- 종목 기본정보 조회 (`/v1/korean-stock/base-info`)
- 종목 일별 시세 조회 (`/v1/korean-stock/trade-info`)
- 종목명이 모호할 때 시장/종목코드 후보를 먼저 좁히기
## 가장 중요한 규칙
기본 경로는 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 이다.
사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
upstream 참고 구현은 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 이 레포의 기본 사용법은 로컬 MCP 설치가 아니라 **proxy first** 다.
## 먼저 필요한 것
없음. 인터넷 연결만 있으면 된다.
## 추천 조회 순서
1. 종목명이 애매하면 `/v1/korean-stock/search?q=...` 로 후보를 먼저 찾는다.
2. 후보에서 `market`, `code` 를 확인한다.
3. 기본 정보가 필요하면 `/v1/korean-stock/base-info` 를 호출한다.
4. 가격/거래량이 필요하면 `/v1/korean-stock/trade-info` 를 호출한다.
5. 휴장일이면 `bas_dd` 를 최근 영업일로 다시 지정한다.
## 검색 예시
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
## 기본정보 예시
```bash
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'
```
## 일별 시세 예시
```bash
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'
```
## 응답 해석 팁
- `code` 는 보통 6자리 단축코드다.
- `standard_code` 는 KRX 표준코드다.
- `close_price`, `trading_volume`, `market_cap` 은 숫자로 정규화돼 온다.
- `base_date`/`bas_dd` 는 일별 snapshot 날짜다.
- 휴장일/장마감 전에는 빈 결과나 `not_found` 가 나올 수 있다.
## 답변 템플릿 권장
- 종목명 / 시장 / 종목코드
- 기준일
- 종가 / 등락률 / 거래량 / 시가총액
- 필요하면 상장일 / 액면가 / 상장주식수
- 마지막 한 줄: `KRX 공식 데이터 기준이며 투자 조언은 아닙니다.`
## 에러/제약
- 잘못된 `market`, `code`, `bas_dd` 형식은 400
- proxy 서버에 `KRX_API_KEY` 가 없으면 503
- upstream KRX 오류는 502
- 기준일에 종목을 찾지 못하면 404 `not_found`
## 참고 링크
- 원본 MCP 서버(참고용): `https://github.com/jjlabsio/korea-stock-mcp`
- 공식 KRX Open API: `https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`

View file

@ -0,0 +1,72 @@
# 마켓컬리 상품 조회 가이드
## 이 기능으로 할 수 있는 일
- 마켓컬리 상품 키워드 검색
- 현재 가격 확인
- 필요하면 원가/할인가 여부 확인
- 품절 여부와 배송 타입 확인
- 상품 링크 반환
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
## 입력값
- 상품명 또는 검색어
- 예: `우유`
- 예: `딸기`
- 예: `닭가슴살`
## 공식 표면
- search list: `https://api.kurly.com/search/v4/sites/market/normal-search?keyword=<keyword>&page=1`
- search count: `https://api.kurly.com/search/v3/sites/market/normal-search/count?keyword=<keyword>&filters=&allow_replace=true`
- goods detail page: `https://www.kurly.com/goods/<productNo>`
## 기본 흐름
1. 상품명/검색어가 없으면 먼저 물어봅니다.
2. `normal-search` 로 상품 후보를 찾습니다.
3. 후보가 너무 많으면 `count` endpoint 로 검색 결과 규모를 먼저 보여 줍니다.
4. 결과에서 상품명, 현재 가격, 할인율, 품절 여부, 배송 타입, 링크를 짧고 **보수적으로** 정리합니다.
5. 필요하면 `goods/<productNo>` 페이지의 `__NEXT_DATA__` 를 읽어 상세 정보를 보조 확인합니다.
6. 가격/품절/노출 정보는 시점에 따라 달라질 수 있으므로 조회 시각 기준 참고값이라고 답합니다.
## 예시
```js
const { countProducts, getProductDetail, searchProducts } = require("market-kurly-search")
async function main() {
const count = await countProducts("우유")
const search = await searchProducts("우유")
const detail = await getProductDetail(search.items[0].productNo)
console.log({ count, firstItem: search.items[0], detail })
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 실전 운영 팁
- 검색어가 너무 넓으면 브랜드, 용량, 맛, 카테고리를 다시 물어보는 편이 안전합니다.
- 할인 상품은 `discountedPrice` 가 현재 가격이고 `salesPrice` 가 기준 가격일 수 있습니다.
- 품절 여부가 `false` 여도 실제 결제 시점에는 달라질 수 있으니 주문 가능을 확정처럼 말하면 안 됩니다.
- 비로그인 조회로는 장바구니/주문/주소 기반 배송 가능 여부를 확정할 수 없습니다.
## 라이브 확인 메모
2026-04-09 기준 아래 공개 호출이 로그인 없이 응답했습니다.
- `GET /search/v4/sites/market/normal-search?keyword=우유&page=1``no`, `name`, `salesPrice`, `discountedPrice`, `discountRate`, `isSoldOut`, `deliveryTypeNames` 확인
- `GET /search/v3/sites/market/normal-search/count?keyword=우유&filters=&allow_replace=true``count = 468` 확인
- `GET /goods/5063110``__NEXT_DATA__` 에서 상품명, 가격, 품절 여부, 배송 타입 확인
즉, **2026-04-09 기준으로는 마켓컬리 상품 검색과 가격 조회를 로그인 없이 구현할 수 있음** 을 다시 검증했습니다. 다만 이 표면은 웹 내부 사용 경로이므로 이후 스키마/헤더 요구사항이 바뀌면 수정이 필요할 수 있습니다.

View file

@ -0,0 +1,87 @@
# 의약품 안전 체크 가이드
## 이 기능으로 할 수 있는 일
- 식약처 공식 `의약품개요정보(e약은요)` 조회
- 식약처 공식 `안전상비의약품 정보` 조회
- 제품명 기준으로 효능, 사용법, 주의사항, 상호작용, 이상반응, 보관법 요약
- 증상 언급 시 **인터뷰-first** 흐름으로 red flag 확인
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- `DATA_GO_KR_API_KEY`
- 설치된 `mfds-drug-safety` skill 안에 `scripts/mfds_drug_safety.py` helper 포함
> 이 helper 는 증상 질문에 대한 직접 진단을 하지 않는다. 증상이 있으면 바로 단정하지 말고 먼저 되묻는다.
## 공식 표면
- 공공데이터포털 문서: `https://www.data.go.kr/data/15075057/openapi.do`
- e약은요 endpoint: `https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15097208/openapi.do`
- 안전상비의약품 endpoint: `https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq`
## 권장 인터뷰 질문
증상이나 복용상황이 있으면 먼저 아래를 확인한다.
- 누가 복용하려는지 (본인/아이/임산부/고령자)
- 어떤 약을 이미 먹었는지 / 지금 먹으려는지
- 언제부터 얼마나 복용했는지
- 현재 증상과 시작 시점
- 복용 중인 다른 약, 기저질환, 알레르기
- 응급 red flag: `호흡곤란`, `의식저하`, `심한 발진`, `지속되는 구토/흉통`
red flag 가 있으면 **즉시 119·응급실·의료진** 안내가 우선이다.
## 기본 흐름
1. `python3 scripts/mfds_drug_safety.py interview ...` 로 되묻기 질문 세트를 준비한다.
2. red flag 가 없고 약 이름이 확인되면 `lookup` 으로 공식 정보를 조회한다.
3. 효능/주의/상호작용/부작용을 짧게 정리한다.
4. `같이 먹어도 되나?` 질문에는 공식 문구를 근거로만 말하고 최종 판단은 약사·의료진 확인이 필요하다고 밝힌다.
## CLI 예시
```bash
python3 scripts/mfds_drug_safety.py interview \
--question "타이레놀이랑 판콜 같이 먹어도 되나요?" \
--symptoms "두드러기와 어지러움"
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-name "판콜"
```
## 출력 예시 포맷
```json
{
"query": {
"item_names": ["타이레놀", "판콜"],
"limit": 5
},
"items": [
{
"source": "drug_easy_info",
"item_name": "타이레놀정160밀리그램",
"company_name": "한국얀센",
"efficacy": "감기로 인한 발열 및 동통에 사용합니다.",
"interactions": "다른 해열진통제와 함께 복용하지 마십시오."
}
]
}
```
## 검증 메모
2026-04-08 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
- `python3 scripts/mfds_drug_safety.py --help`
- `python3 scripts/mfds_drug_safety.py interview --question "타이레놀이랑 판콜 같이 먹어도 되나요?" --symptoms "두드러기와 어지러움"`
- `DATA_GO_KR_API_KEY` 를 소스한 뒤 live endpoint 호출을 시도해 현재 키/활용승인 상태에서 `HTTP 403` 이 surfaced 되는지 확인
즉, helper 자체와 인터뷰 흐름은 검증했고, live 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.

View file

@ -0,0 +1,92 @@
# 식품 안전 체크 가이드
## 이 기능으로 할 수 있는 일
- 식약처 공식 부적합 식품 목록 조회
- 식품안전나라 회수·판매중지 공개 목록(sample/live) 확인
- 제품명/업체명 기준 로컬 필터링 요약
- 증상 언급 시 **인터뷰-first** 흐름으로 red flag 확인
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- 부적합 식품 live 조회용 `DATA_GO_KR_API_KEY`
- 회수정보 smoke/demo 용 `--sample-recalls` 또는 식품안전나라 API key
- 설치된 `mfds-food-safety` skill 안에 `scripts/mfds_food_safety.py` helper 포함
> 이 helper 는 **직접 진단**을 하지 않는다. 먹어도 되는지 바로 단정하지 않는다. 증상이 있으면 바로 단정하지 말고 먼저 되묻는다.
## 공식 표면
- 공공데이터포털 문서: `https://www.data.go.kr/data/15056516/openapi.do`
- 부적합 식품 endpoint: `https://apis.data.go.kr/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01`
- 식품안전나라 회수·판매중지 문서: `https://www.data.go.kr/data/15074318/openapi.do`
- 식품안전나라 API 안내: `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0490&svc_type_cd=API_TYPE06`
- 식품안전나라 회수 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/5`
## 권장 인터뷰 질문
증상이나 섭취상황이 있으면 먼저 아래를 확인한다.
- 누가 먹었는지 (본인/아이/임산부/고령자)
- 무엇을 언제 얼마나 먹었는지
- 같이 먹은 음식/술/약
- 복통/구토/설사/발진 등 증상과 시작 시점
- 기저질환, 임신 여부, 알레르기
- 응급 red flag: `혈변`, `탈수`, `호흡곤란`, `의식저하`, `심한 복통/고열`
red flag 가 있으면 **즉시 응급실·119·의료진** 안내가 우선이다.
## 기본 흐름
1. `python3 scripts/mfds_food_safety.py interview ...` 로 되묻기 질문 세트를 준비한다.
2. 부적합 식품 live 조회가 가능하면 `PrsecImproptFoodInfoService03/getPrsecImproptFoodList01` 를 조회한다.
3. 필요하면 식품안전나라 `I0490` 회수 sample/live 목록을 함께 확인한다.
4. 제품명/업체명/사유 기준으로 로컬 필터링 후 짧게 정리한다.
5. 먹어도 되는지 단정하지 않고, 증상이 있으면 의료진 상담을 우선한다.
## CLI 예시
```bash
python3 scripts/mfds_food_safety.py interview \
--question "이 김밥 먹어도 되나요?" \
--symptoms "복통과 설사"
```
```bash
python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
```
## 출력 예시 포맷
```json
{
"query": "김밥",
"items": [
{
"source": "foodsafetykorea_recall",
"product_name": "맛있는김밥",
"company_name": "예시식품",
"reason": "대장균 기준 규격 부적합"
}
],
"warnings": []
}
```
## 검증 메모
2026-04-08 기준 로컬에서 아래를 실제 실행해 helper 동작을 확인했다.
- `python3 scripts/mfds_food_safety.py --help`
- `python3 scripts/mfds_food_safety.py interview --question "이 김밥 먹어도 되나요?" --symptoms "복통과 설사"`
- `python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5`
- `DATA_GO_KR_API_KEY` 를 소스한 뒤 live 부적합 식품 endpoint 호출을 시도해 현재 키/활용승인 상태에서 `HTTP 403` 이 surfaced 되는지 확인
즉, helper 자체와 공개 sample 회수 흐름은 검증했고, live 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.

View file

@ -0,0 +1,76 @@
# 지하철 분실물 조회 가이드
## 이 기능으로 할 수 있는 일
- 역명/물품명/기간 기준으로 LOST112 공식 검색 조건 정리
- 서울교통공사 유실물센터 공식 진입점 안내
- `SITE=V` 기준 지하철 등 외부기관 습득물 검색 payload 생성
- 공식 페이지 reachability를 보수적으로 점검
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- `python3`, `curl` 사용 가능 환경
- 인터넷 연결
## v1 범위
현재 공개 API는 명확하지 않으므로, 이 기능은 **안내형/하이브리드** 범위로 제공된다.
- 공식 LOST112 검색폼에 넣을 값을 구조화해 준다.
- 서울교통공사 유실물센터를 같이 열 수 있게 한다.
- 자동 결과 수집은 보장하지 않는다.
## 공식 경로
- LOST112 습득물 목록: `https://www.lost112.go.kr/find/findList.do`
- 서울교통공사 유실물센터: `https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541`
LOST112에서 실제로 중요한 검색 조건은 아래와 같다.
- `SITE=V`: 경찰 이외 기관(지하철, 공항 등)
- `DEP_PLACE`: 보관장소/역명 키워드
- `PRDT_NM`: 물품명
- `START_YMD`, `END_YMD`: 검색 기간
## 기본 흐름
1. 사용자에게 역명, 물품명, 대략의 날짜를 먼저 받는다.
2. helper로 LOST112 payload와 referer가 포함된 runnable `curl` 예시를 생성한다. 예시 `curl` 은 느린 공식 응답을 감안해 `--max-time 60` 을 포함하고, 응답 HTML을 `lost112-search-result.html` 로 저장한다.
3. 역명 그대로 검색한 뒤, 결과가 없으면 `역` 없는 키워드나 호선명으로 넓힌다.
4. 서울교통공사 유실물센터 페이지를 함께 열어 후속 절차를 확인한다.
## 예시
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14
```
live reachability 확인까지 하려면:
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14 \
--verify-live
```
## 출력 예시에서 확인할 점
- `payload.SITE``V` 로 고정되어 있는지
- `payload.DEP_PLACE` 에 역명 키워드가 들어갔는지
- `curl_example``--referer https://www.lost112.go.kr/` 가 포함되어 있는지
- `curl_example``--max-time 60` 이 포함되어 있는지
- `curl_example``--output lost112-search-result.html` 가 포함되어 있는지
- `curl_example``https://www.lost112.go.kr/find/findList.do` 를 사용하는지
- `official_sources` 에 LOST112 와 서울교통공사 URL이 모두 들어 있는지
## 주의할 점
- 공식 사이트 응답이 느릴 수 있다.
- 역명 표기가 실제 보관장소 표기와 다를 수 있다.
- 공개 API가 확인되기 전까지는 완전 자동 조회형으로 취급하지 않는다.

View file

@ -1,40 +1,69 @@
# 우편번호 검색 가이드
# 우편번호 + 영문주소 검색 가이드
## 이 기능으로 할 수 있는 일
- 주소 키워드로 공식 우체국 우편번호 조회
- 같은 도로명/건물명 후보가 여러 개일 때 상위 결과 비교
- 같은 후보의 국문 도로명/지번 주소와 공식 영문 주소를 함께 비교
- 검색 결과가 없을 때 바로 재검색 키워드 조정
## 먼저 필요한 것
- 인터넷 연결
- `curl`
- 선택 사항: `python3`
- `python3`
## 입력값
- 주소 키워드
- 예: `세종대로 209`
- 예: `판교역로 235`
- 예: `서울특별시 강남구 테헤란로 123`
- 예: `역삼동 648-23`
## 기본 흐름
1. 비공식 지도/블로그 검색으로 우회하지 말고 우체국 공식 검색 페이지를 먼저 조회합니다.
1. 비공식 변환기나 블로그 표기로 우회하지 말고 우체국 공식 통합 검색 페이지를 먼저 조회합니다.
2. 주소 키워드를 `keyword` 파라미터로 넘겨 HTML 결과를 받습니다.
3. 결과에서 우편번호(`sch_zipcode`)와 표준 주소(`sch_address1`), 건물명(`sch_bdNm`)을 추출합니다.
3. 결과에서 `viewDetail(zip, roadAddress, englishAddress, jibunAddress, rowIndex)` 패턴을 추출합니다.
4. 후보가 여러 개면 상위 3~5개만 간단히 비교해 줍니다.
5. 전송 timeout/reset이 나면 `curl` 재시도 옵션을 유지한 채 한 번 더 돌리고, 그래도 실패하면 `세종대로 209` 같은 짧은 도로명 + 건물번호 → `서울 종로구 세종대로 209` 같은 시/군/구 포함 전체 주소 → 동/리 + 지번 순으로 재시도합니다.
5. 전송 timeout/reset이 나면 `curl` 재시도 옵션을 유지한 채 한 번 더 돌리고, 그래도 실패하면 `테헤란로 123` 같은 짧은 도로명 + 건물번호 → `서울 강남구 테헤란로 123` 같은 시/군/구 포함 전체 주소 → 동/리 + 지번 순으로 재시도합니다.
## 공식 endpoint
```text
https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
```
검색 결과 표에는 `English/집배코드` 열이 있고, 실제 값은 `viewDetail(...)` 인자와 상세 행에 함께 들어 있습니다.
## 예시
```bash
python3 scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
./scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
```
```json
{
"query": "서울특별시 강남구 테헤란로 123",
"results": [
{
"zip_code": "06133",
"road_address": "서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
"english_address": "123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
"jibun_address": "서울특별시 강남구 역삼동 648-23 (여삼빌딩)"
}
]
}
```
## raw HTML 추출 예시
```bash
python3 - <<'PY'
import html
import re
import subprocess
query = "세종대로 209"
query = "서울특별시 강남구 테헤란로 123"
cmd = [
"curl",
"--http1.1",
@ -53,29 +82,27 @@ cmd = [
"--get",
"--data-urlencode",
f"keyword={query}",
"https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp",
"https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm",
]
result = subprocess.run(
page = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
page = result.stdout
).stdout
matches = re.findall(
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
page,
re.S,
)
if not matches:
raise SystemExit("검색 결과가 없습니다.")
for zip_code, address, building in matches[:5]:
suffix = f" ({building})" if building else ""
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
for zip_code, road_address, english_address, jibun_address in matches[:5]:
print(zip_code)
print(html.unescape(road_address))
print(html.unescape(english_address))
print(html.unescape(jibun_address))
print("---")
PY
```
@ -83,15 +110,14 @@ PY
- 쉘 래퍼나 에이전트 환경에서는 here-doc + Python one-liner보다 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 파싱하는 쪽이 더 안전합니다.
- 응답 일부만 빨리 보려고 `curl ... | head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있습니다. 이때는 전체 응답을 임시 파일에 저장한 뒤 확인합니다.
- 재시도 순서는 보통 `세종대로 209` 같은 짧은 도로명 + 건물번호 → `서울 종로구 세종대로 209` 같은 전체 주소 → 동/리 + 지번 순이 가장 덜 헷갈립니다.
- 기본은 우체국 공식 영문 주소를 그대로 유지하고, 외부 서비스가 국가명 축약을 싫어할 때만 후처리를 따로 합니다.
## 프로토콜/클라이언트 제약
- 현재 ePost 엔드포인트는 로컬 기본 `urllib` 전송으로 붙으면 TLS/HTTP 협상 중 연결 reset이 날 수 있습니다.
- 현재 ePost 엔드포인트는 같은 curl 플래그여도 간헐적인 timeout/reset이 있을 수 있으므로 문서 기본 예시는 `--retry 3 --retry-all-errors --retry-delay 1`을 포함합니다.
- 현재 ePost 통합 검색 엔드포인트는 같은 curl 플래그여도 간헐적인 timeout/reset이 있을 수 있으므로 기본 예시는 `--retry 3 --retry-all-errors --retry-delay 1`을 포함합니다.
- 문서 기본 예시는 `curl --http1.1 --tls-max 1.2` 전송을 사용하고, Python은 응답 파싱/정리에만 사용합니다.
- 바깥쪽 Python `timeout`은 두지 않고 `curl` 자체 제한(`--max-time` + `--retry`)으로 전체 전송 시간을 제어합니다.
- 다른 클라이언트를 쓰더라도 최소한 HTTP/1.1 + TLS 1.2 경로에서 실제 응답을 먼저 확인한 뒤 정규식 추출을 붙입니다.
- 다른 클라이언트를 쓰더라도 최소한 HTTP/1.1 + TLS 1.2 경로에서 실제 응답을 먼저 확인한 뒤 `viewDetail(...)` 추출을 붙입니다.
## 주의할 점

View file

@ -49,25 +49,35 @@ npx --yes skills add <owner/repo> \
--skill kleague-results \
--skill lck-analytics \
--skill toss-securities \
--skill hipass-receipt \
--skill lotto-results \
--skill kakaotalk-mac \
--skill korean-law-search \
--skill real-estate-search \
--skill korean-stock-search \
--skill household-waste-info \
--skill mfds-drug-safety \
--skill mfds-food-safety \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
--skill subway-lost-property \
--skill daiso-product-search \
--skill market-kurly-search \
--skill olive-young-search \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \
--skill coupang-product-search \
--skill bunjang-search \
--skill used-car-price-search \
--skill korean-spell-check \
--skill k-schoollunch-menu
--skill korean-character-count
```
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
@ -79,9 +89,15 @@ npx --yes skills add <owner/repo> \
--skill ktx-booking \
--skill korean-law-search \
--skill real-estate-search \
--skill mfds-drug-safety \
--skill mfds-food-safety \
--skill cheap-gas-nearby \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill hipass-receipt \
--skill seoul-subway-arrival \
--skill subway-lost-property \
--skill korea-weather \
--skill fine-dust-location
```
@ -101,8 +117,30 @@ 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-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)를 본다.
### `korean-stock-search` proxy quickstart
`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'`
- 검색 결과에서 `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'
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'
```
### `olive-young-search` upstream CLI quickstart
`olive-young-search` 는 upstream 원본 [`hmmhmmhm/daiso-mcp`](https://github.com/hmmhmmhm/daiso-mcp) / npm package [`daiso`](https://www.npmjs.com/package/daiso) 를 그대로 사용한다.
@ -134,6 +172,53 @@ node dist/bin.js get /api/oliveyoung/products --keyword 선크림 --size 5 --jso
node dist/bin.js get /api/oliveyoung/inventory --keyword 선크림 --storeKeyword 명동 --size 5 --json
```
### `bunjang-search` upstream CLI quickstart
`bunjang-search` 는 upstream 원본 [`pinion05/bunjangcli`](https://github.com/pinion05/bunjangcli) / npm package [`bunjang-cli`](https://www.npmjs.com/package/bunjang-cli) 를 그대로 사용한다.
- 기본 경로는 **CLI first** 다.
- 가장 빠른 smoke test 는 `npx --yes bunjang-cli --help`
- 검색/상세조회는 로그인 없이도 먼저 검증할 수 있다.
- `favorite` / `chat` / `purchase` 는 로그인 세션이 필요하므로 **선택적 로그인 플로우**로만 안내한다.
- `auth login` 은 headful 브라우저 + TTY(interactive 터미널) 가 필요하다.
- 대량 수집은 `--start-page`, `--pages`, `--max-items`, `--with-detail`, `--output` 조합을 우선 쓴다.
- AI 분석용 chunk 는 `--ai --output <directory>` 로 만든다.
```bash
npx --yes bunjang-cli --help
npx --yes bunjang-cli --json auth status
npx --yes bunjang-cli --json search "아이폰" --max-items 3 --sort date
npx --yes bunjang-cli --json item get 354957625
npx --yes bunjang-cli search "아이폰" --start-page 1 --pages 2 --max-items 20 --with-detail --output artifacts/bunjang-iphone.json
npx --yes bunjang-cli search "아이폰" --start-page 1 --pages 2 --max-items 20 --with-detail --ai --output artifacts/bunjang-iphone-ai
```
로그인된 interactive 세션에서만 아래 액션을 진행한다.
```bash
npx --yes bunjang-cli auth login
npx --yes bunjang-cli --json favorite list
npx --yes bunjang-cli --json favorite add 354957625
npx --yes bunjang-cli --json favorite remove 354957625
npx --yes bunjang-cli --json chat list
npx --yes bunjang-cli --json chat start 354957625 --message "안녕하세요"
npx --yes bunjang-cli --json chat send 84191651 --message "상품 상태 괜찮을까요?"
```
`korean-patent-search` 는 설치된 skill payload 안의 helper를 그대로 쓴다.
- helper 환경변수는 `KIPRIS_PLUS_API_KEY`
- 실제 API 요청에서는 이 값을 `ServiceKey` 쿼리 파라미터로 보낸다
- 공공데이터포털에서 복사한 percent-encoded key를 그대로 넣어도 helper가 한 번 정규화해서 double-encoding 없이 보낸다
- KIPRIS Plus / 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 심의승인 대상이다
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리" --year 2024 --num-rows 5
python3 scripts/patent_search.py --application-number 1020240001234
```
로컬 저장소에서 바로 전체 설치 테스트:
```bash
@ -168,7 +253,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp daiso
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
export NODE_PATH="$(npm root -g)"
```
@ -194,12 +279,26 @@ python3 -m pip install SRTrain korail2 pycryptodome
python3 scripts/sillok_search.py --query "훈민정음" --king 세종 --year 1443
```
한국 특허 정보 검색 helper는 설치된 `korean-patent-search` skill 안의 `scripts/patent_search.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
```
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다."
```
한국어 글자 수 세기 helper는 별도 외부 패키지 없이 `node` 18+ 만 있으면 된다.
```bash
node scripts/korean_character_count.js --text "가나다"
node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profile neis --format text
```
운영체제 정책이나 권한 때문에 전역 설치가 막히면, 임의의 대체 구현으로 넘어가지 말고 그 차단 사유를 사용자에게 설명한 뒤 다음 설치 단계를 정합니다.
## npx도 없으면
@ -217,9 +316,13 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `srt-booking`
- `ktx-booking`
- `seoul-subway-arrival`
- `korea-weather`
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`
- `korean-patent-search`
- `hipass-receipt`
- `korean-stock-search`
- `household-waste-info`
- `cheap-gas-nearby`
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)

View file

@ -10,23 +10,32 @@
- K리그 경기 결과 조회 스킬 출시
- LCK 경기 분석 스킬 출시
- 토스증권 조회 스킬 출시
- 하이패스 영수증 발급 스킬 출시
- 로또 당첨번호
- 서울 지하철 도착 정보
- 한국 날씨 조회 스킬 출시
- 사용자 위치 미세먼지 조회 스킬 출시
- 한강 수위 정보 조회 스킬 출시
- 한국 법령 검색 스킬 출시
- 한국 부동산 실거래가 조회 스킬 출시
- 의약품 안전 체크 스킬 출시
- 식품 안전 체크 스킬 출시
- 한국 주식 정보 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 한국 특허 정보 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시
- 마켓컬리 상품 조회 스킬 출시
- 올리브영 검색 스킬 출시
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
- 번개장터 검색 스킬 출시
- 중고차 가격 조회 스킬 출시
- 한국어 맞춤법 검사 스킬 출시
- 한국어 글자 수 세기 스킬 출시
## v1.5 candidates
@ -109,10 +118,10 @@
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
#### 한국 기상청 날씨/특보
#### 한국 기상청 특보/중기예보 확장
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간
- 장점: 이미 선출시한 한국 날씨 조회 스킬에 특보/중기예보를 붙여 생활 정보 깊이를 늘릴 수 있
- 이유: 단기예보 다음 단계로 자연스럽게 확장 가능하
### 기존 탐색 후보

View file

@ -25,11 +25,12 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 self-host 또는 배포 확인이 끝난 proxy URL 만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy
@ -61,9 +62,11 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_KTX_ID`
- `KSKILL_KTX_PASSWORD`
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하고, 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 학교 급식·학교 검색(NEIS)은 프록시가 `KEDU_INFO_KEY` 로 나이스 Open API `KEY` 를 붙이므로 사용자 쪽 키가 불필요하다. `KEDU_INFO_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KEDU_INFO_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한국 날씨 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -25,6 +25,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -33,7 +34,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
실제 값을 채운다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채워야 한다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
@ -41,8 +42,12 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
## 확인
```bash
@ -65,8 +70,12 @@ bash scripts/check-setup.sh
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
@ -76,10 +85,14 @@ bash scripts/check-setup.sh
- [SRT 예매 가이드](features/srt-booking.md)
- [KTX 예매 가이드](features/ktx-booking.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [한국 날씨 조회 가이드](features/korea-weather.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [한국 특허 정보 검색 가이드](features/korean-patent-search.md)
- [하이패스 영수증 발급 가이드](features/hipass-receipt.md)
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
- [보안/시크릿 정책](security-and-secrets.md)

View file

@ -8,6 +8,11 @@
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- `kbo-game`: https://github.com/vkehfdl1/kbo-game
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- 하이패스 메인: https://www.hipass.co.kr/main.do
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
- 하이패스 사용내역 조회 진입: https://www.hipass.co.kr/usepculr/InitUsePculrTabSearch.do
- 하이패스 사용내역 이용안내: https://www.hipass.co.kr/html/guide/siteguide_6.jsp
- 하이패스 사용내역 이용안내(do): https://www.hipass.co.kr/info/guide/siteguide_6.do
- K League 일정/결과 JSON: https://www.kleague.com/getScheduleList.do
- K League 팀 순위 JSON: https://www.kleague.com/record/teamRank.do
- jerjangmin original `lck-analytics` skill pack: https://github.com/jerjangmin/share/tree/main/SKILL/lck-analytics
@ -22,6 +27,19 @@
- `hwp-mcp`: https://github.com/jkf87/hwp-mcp
- korean-law-mcp: https://github.com/chrisryugj/korean-law-mcp
- real-estate-mcp: https://github.com/tae0y/real-estate-mcp/tree/main
- 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
- 공공데이터포털 안전상비의약품 정보: https://www.data.go.kr/data/15097208/openapi.do
- 식약처 안전상비의약품 endpoint: https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq
- 공공데이터포털 검사 부적합 식품정보: https://www.data.go.kr/data/15056516/openapi.do
- 식약처 부적합 식품 endpoint: https://apis.data.go.kr/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01
- 공공데이터포털 식품 회수·판매중지 정보: https://www.data.go.kr/data/15074318/openapi.do
- 식품안전나라 I0490 안내: https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0490&svc_type_cd=API_TYPE06
- 식품안전나라 I0490 sample: https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/5
- KRX OPEN API 메인: https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd
- KRX 종목 기본정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info
- KRX 일별 매매정보 API (KOSPI): http://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd
- MOLIT 아파트 매매 실거래가 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade
- MOLIT 아파트 전월세 API: https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent
- MOLIT 오피스텔 매매 API: https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade
@ -41,6 +59,11 @@
- 바른한글 이전 버전: https://nara-speller.co.kr/old_speller/
- 바른한글 이전 버전 결과 POST 표면: https://nara-speller.co.kr/old_speller/results
- 바른한글 robots: https://nara-speller.co.kr/robots.txt
- Unicode Text Segmentation (UAX #29): https://www.unicode.org/reports/tr29/
- Unicode Normalization Forms (UAX #15): https://www.unicode.org/reports/tr15/
- WHATWG Encoding Standard: https://encoding.spec.whatwg.org/
- Node Buffer.byteLength: https://nodejs.org/api/buffer.html
- 2023 학교생활기록부 기재요령(경기도교육청 PDF): https://www.goe.go.kr/resource/old/BBSMSTR_000000030136/BBS_202302211104253520.pdf
- 다이소몰 매장 검색: https://www.daisomall.co.kr/api/ms/msg/selStr
- 다이소몰 매장 검색어 목록: https://www.daisomall.co.kr/api/ms/msg/selStrSrchKeyword
- 다이소몰 매장 상세: https://www.daisomall.co.kr/api/dl/dla-api/selStrInfo
@ -49,6 +72,9 @@
- 다이소몰 상품 요약 목록: https://www.daisomall.co.kr/ssn/search/GoodsMummResult
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 마켓컬리 검색 API(v4): https://api.kurly.com/search/v4/sites/market/normal-search
- 마켓컬리 검색 개수 API(v3): https://api.kurly.com/search/v3/sites/market/normal-search/count
- 마켓컬리 상품 상세 페이지 예시: https://www.kurly.com/goods/5063110
- olive-young / multi-retail upstream repo (`hmmhmmhm/daiso-mcp`): https://github.com/hmmhmmhm/daiso-mcp
- olive-young CLI package (`daiso`): https://www.npmjs.com/package/daiso
- olive-young stores API: https://mcp.aka.page/api/oliveyoung/stores
@ -57,6 +83,8 @@
- daiso/olive-young public MCP endpoint: https://mcp.aka.page/mcp
- coupang-mcp (MCP 서버): https://github.com/uju777/coupang-mcp
- coupang-mcp endpoint: https://yuju777-coupang-mcp.hf.space/mcp
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
- 블루리본 메인: https://www.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
@ -65,11 +93,18 @@
- 조선왕조실록 메인: https://sillok.history.go.kr
- 조선왕조실록 검색 결과: https://sillok.history.go.kr/search/searchResultList.do
- 조선왕조실록 기사 상세: https://sillok.history.go.kr/id/kda_12512030_002
- KIPRIS Plus 특허/실용신안 API 목록: https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100
- 공공데이터포털 특허/실용신안 정보 검색 서비스: https://www.data.go.kr/data/15058788/openapi.do
- KIPRIS Plus 특허/실용신안 검색 endpoint: https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getWordSearch
- KIPRIS Plus 특허/실용신안 서지상세 endpoint: https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getBibliographyDetailInfoSearch
- Opinet 오픈 API 안내: https://www.opinet.co.kr/user/custapi/openApiInfo.do
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
- 기상청 단기예보 조회서비스: https://www.data.go.kr/data/15084084/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do
- 한강홍수통제소 Open API 레퍼런스: https://www.hrfco.go.kr/web/openapiPage/reference.do
@ -77,6 +112,7 @@
- 한강홍수통제소 Open API 정책: https://www.hrfco.go.kr/web/openapi/policy.do
- 한강홍수통제소 API base: https://api.hrfco.go.kr
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- 우체국 통합 우편번호/영문주소 검색: https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail
- 우체국 배송조회: https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1=

View file

@ -3,6 +3,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KEDU_INFO_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com

107
hipass-receipt/SKILL.md Normal file
View file

@ -0,0 +1,107 @@
---
name: hipass-receipt
description: 공식 하이패스 홈페이지에서 사용자가 직접 로그인한 Chrome 세션을 재사용해 사용내역 조회와 영수증 팝업 진입을 돕는다.
license: MIT
metadata:
category: transport
locale: ko-KR
phase: v1
---
# 하이패스 영수증 발급
## What this skill does
공식 하이패스 홈페이지(`https://www.hipass.co.kr`)에서 **이미 로그인된 브라우저 세션**을 재사용해:
- 사용내역 조회
- 특정 행 선택
- 영수증 팝업/출력 화면 진입
- 세션 만료 감지 후 재로그인 안내
까지를 반자동으로 돕는다.
## Hard limits
- **로그인은 반드시 사용자가 직접 해야 한다.**
- 이 스킬은 **로그인된 세션에서만** 동작한다.
- ID/PW, 인증코드, OTP, 공동인증서 절차를 자동 입력하지 않는다.
- `JSESSIONID` 쿠키만 저장해 장시간 재사용하는 방식은 지원하지 않는다.
- 권장 세션 형태는 **Playwright persistent context** 또는 Chrome `user-data-dir` / remote-debugging 재사용이다.
- **세션이 만료되면 즉시 중단하고 다시 로그인**해야 한다.
## Why this design
현재 공개 페이지 기준으로:
- 로그인 페이지와 메인 페이지에 `session_time=1200` 이 노출된다.
- 세션 연장은 `/comm/sessionCheck.do`
- 세션 종료는 `/comm//sessionout.do`
- 미로그인/세션 종료 보호 응답은 `mgs_type 11/12``/comm/lginpg.do` 로 이동한다.
- 사용내역 조회는 `/usepculr/InitUsePculrTabSearch.do``hpForm` submit → `/usepculr/UsePculrTabSearchList.do` 흐름이다.
- 영수증은 `/usepculr/UsePculrReceiptPrint.do` 팝업 진입으로 이어진다.
즉 v1은 **“로그인된 Chrome 세션 재사용”** 이 가장 현실적이다.
## Prerequisites
- macOS 또는 Chrome 실행 가능한 환경
- `npm install hipass-receipt` 또는 이 레포에서 `npm install` (`playwright-core` 포함)
- Chrome 원격 디버깅 포트 사용 가능
- 사용자가 직접 하이패스 로그인 가능
## Workflow
### 1. 전용 Chrome 프로필로 로그인 브라우저를 띄운다
```bash
hipass-receipt chrome-command --profile-dir "$HOME/.cache/k-skill/hipass-chrome" --debugging-port 9222
```
위 명령이 출력한 Chrome 실행문으로 브라우저를 띄운 뒤, 사용자가 직접 `https://www.hipass.co.kr/comm/lginpg.do` 에 로그인한다.
### 2. 사용내역을 조회한다
```bash
hipass-receipt list \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--page-size 30
```
- 카드사/암호화 카드번호를 알고 있으면 `--encrypted-card-number` 등으로 더 좁힐 수 있다.
- `--encrypted-card-number` 는 CLI의 기존 `--ecd-no` 별칭이다.
- 결과 JSON에서 `rowIndex` 를 확인한다.
### 3. 특정 row의 영수증 팝업을 연다
```bash
hipass-receipt receipt \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--row-index 1
```
- 선택한 행의 `영수증`/`출력` control 을 클릭한다.
- 팝업이 열리면 URL/title 을 반환한다.
## Response policy
- “로그인 필수”, “세션 만료 시 재로그인 필요”를 항상 명확히 적는다.
- 하이패스 계정 비밀번호를 받아 저장하거나 새 env var를 만들지 않는다.
- 세션이 만료됐으면 즉시 실패시키고 `/comm/lginpg.do` 재로그인만 안내한다.
- v1 범위를 넘어서는 완전 무인 로그인 유지/백그라운드 재인증은 약속하지 않는다.
## Verification
- 자동 검증: fixture 기반 query/parser/session-detection 테스트
- smoke 검증: `hipass-receipt fixture-demo --fixture ...`
- 최종 실서비스 검증: **로그인된 세션으로 수동 smoke test**
## Done when
- 로그인된 세션으로 사용내역 조회가 가능하다.
- 특정 row를 선택해 영수증 팝업 진입을 시도할 수 있다.
- 세션 종료 응답을 감지하면 재로그인을 요구한다.

View file

@ -68,6 +68,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -76,17 +77,21 @@ chmod 0600 ~/.config/k-skill/secrets.env
유저에게 물어서 실제 값을 채운다.
서울 지하철 도착정보는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
서울 지하철 도착정보와 한국 날씨 조회는 hosted public route rollout 이 끝나기 전까지 `KSKILL_PROXY_BASE_URL` 을 self-host 또는 배포 확인이 끝난 proxy URL 로 채운다. 미세먼지, 한강 수위, 주유소 가격은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다.
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
한국 주식 정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `KRX_API_KEY` 를 사용한다.
생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 호출하고, `serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버에서 주입/관리하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
### Missing secret response template
인증 스킬에서 값이 빠졌을 때는 credential resolution order에 따라 확보한다.
@ -98,9 +103,12 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`는 proxy 서버 주입)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 한국 날씨: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.

101
korea-weather/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: korea-weather
description: 한국 날씨를 기상청 단기예보 조회서비스와 프록시 경유로 조회해 요약한다.
license: MIT
metadata:
category: weather
locale: ko-KR
phase: v1
---
# Korea Weather
## What this skill does
기상청 단기예보 조회서비스를 `k-skill-proxy` 경유로 조회해서 한국 날씨를 요약한다.
사용자는 개인 OpenAPI key를 직접 발급할 필요가 없고, proxy 서버에만 `KMA_OPEN_API_KEY` 를 둔다.
## When to use
- "서울 시청 근처 지금 날씨 어때?"
- "부산 날씨 알려줘"
- "위도/경도 기준으로 한국 단기예보 보고 싶어"
## Prerequisites
- optional: `jq`
- self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
## Required environment variables
- `KSKILL_PROXY_BASE_URL` (필수: self-host 또는 배포 확인이 끝난 proxy base URL)
사용자가 공공데이터포털 기상청 API key를 직접 다룰 필요는 없다. 대신 `/v1/korea-weather/forecast` route가 실제로 올라와 있는 proxy URL 을 `KSKILL_PROXY_BASE_URL` 로 받는다. upstream `KMA_OPEN_API_KEY` 는 proxy 서버에서만 관리한다.
## Inputs
- 격자 좌표: `nx`, `ny`
- 또는 위도/경도: `lat`, `lon`
- 선택 사항: `baseDate`, `baseTime`
`baseDate` / `baseTime` 을 생략하면 proxy 가 KST 기준 최신 단기예보 발표 시각을 자동으로 고른다.
## Workflow
### 1. Resolve the proxy base URL
`KSKILL_PROXY_BASE_URL` 로 self-host 또는 배포 확인이 끝난 proxy base URL 을 확인한다.
### 2. Query the short-term forecast endpoint
격자 좌표가 이미 있으면 그대로 넣고, 위도/경도만 있으면 proxy 에 그대로 넘긴다.
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
격자 좌표 예시:
```bash
curl -fsS --get 'https://your-proxy.example.com/v1/korea-weather/forecast' \
--data-urlencode 'nx=60' \
--data-urlencode 'ny=127' \
--data-urlencode 'baseDate=20260405' \
--data-urlencode 'baseTime=0500'
```
### 3. Summarize the response conservatively
가능하면 아래 항목만 먼저 요약한다.
- `TMP`: 기온
- `SKY`: 하늘상태
- `PTY`: 강수형태
- `POP`: 강수확률
- `PCP`: 강수량
- `SNO`: 적설
- `REH`: 습도
- `WSD`: 풍속
응답에는 조회 시점과 `baseDate` / `baseTime` 도 함께 적는다.
## Done when
- 요청 위치의 단기예보 응답이 정리되어 있다
- 조회 시점과 예보 발표 시각이 명시되어 있다
- upstream key가 클라이언트에 노출되지 않았다
## Failure modes
- `KSKILL_PROXY_BASE_URL` 이 비어 있거나 weather route가 아직 배포되지 않은 경우
- `nx` / `ny` 또는 `lat` / `lon` 이 불완전한 경우
- 기상청 quota 초과 또는 upstream 장애
- 선택한 발표 시각에 아직 예보가 준비되지 않은 경우
## Notes
- 공식 API는 `nx` / `ny` 격자를 쓰지만, proxy 는 `lat` / `lon` 도 받아 내부에서 격자로 변환한다.
- 단기예보 category 는 `TMP`, `SKY`, `PTY`, `POP`, `PCP`, `SNO`, `REH`, `WSD` 등을 중심으로 본다.
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.

View file

@ -0,0 +1,97 @@
---
name: korean-character-count
description: Count Korean text deterministically with exact grapheme, line, and byte contracts for self-intros and form limits.
license: MIT
metadata:
category: writing
locale: ko-KR
phase: v1
---
# 한국어 글자 수 세기
## What this skill does
자기소개서, 지원서, 자유서술형 폼처럼 **글자 수 제한이 중요한 한국어 텍스트**를 대상으로 LLM 추정 없이 결정론적으로 카운트한다.
- 기본 글자 수: `Intl.Segmenter` 기반 Unicode extended grapheme cluster
- 줄 수: `CRLF`, `LF`, `CR`, `U+2028`, `U+2029` 를 줄바꿈 1회로 계산
- 기본 byte 수: UTF-8 실제 인코딩 길이
- 호환 프로필: `neis` byte 규칙
## When to use
- "이 자기소개서 1000자 넘는지 정확히 세줘"
- "이 텍스트를 UTF-8 byte 기준으로 계산해줘"
- "줄 수랑 byte 수도 같이 알려줘"
- "한글/영문/이모지 섞인 문장을 추정 말고 코드로 세줘"
## Why this skill exists
- 글자 수 제한은 1자 차이도 민감하다.
- LLM이 글자 수를 눈대중으로 예측하면 재현성이 없다.
- 이 스킬은 **입력을 임의로 trim/정규화하지 않고**, 문서화된 계약으로만 센다.
## Contracts
### `default` profile
- characters: `Intl.Segmenter("ko", { granularity: "grapheme" })`
- bytes: `Buffer.byteLength(text, "utf8")`
- lines:
- empty string => `0`
- non-empty => 줄바꿈 시퀀스 수 + `1`
- `CRLF``2`줄바꿈이 아니라 `1`줄바꿈으로 센다.
### `neis` profile
- characters: `default` 와 동일
- lines: `default` 와 동일
- bytes:
- 한글 grapheme => `3B`
- ASCII grapheme => `1B`
- Enter/줄바꿈 시퀀스 => `2B`
- 그 외 문자는 UTF-8 byte 길이로 fallback
## Prerequisites
- `node` 18+
- 설치된 skill payload 안에 `scripts/korean_character_count.js` helper 포함
- 별도 API 키 없음
## Workflow
1. 텍스트를 직접 받거나 파일/STDIN으로 읽는다.
2. `node scripts/korean_character_count.js` 로 결정론적 카운트를 실행한다.
3. 필요한 프로필(`default`/`neis`)과 출력 형식(`json`/`text`)을 고른다.
4. 결과를 그대로 반환하고, 어떤 계약으로 셌는지 함께 알려준다.
## CLI examples
```bash
node scripts/korean_character_count.js --text "가나다"
node scripts/korean_character_count.js --text $'첫 줄\r\n둘째 줄🙂'
node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profile neis --format text
node scripts/korean_character_count.js --file ./essay.txt --profile default
cat essay.txt | node scripts/korean_character_count.js --stdin --profile neis
```
## Response policy
- 추정하지 말고 helper 결과를 그대로 쓴다.
- 어떤 profile로 셌는지 함께 보여준다.
- 기본값이 필요하면 `default` profile을 사용한다.
- 제출처가 NEIS/학교생활기록부 같은 별도 계약을 요구할 때만 `neis` 를 쓴다.
## Done when
- 글자 수, 줄 수, byte 수가 함께 반환된다.
- `default``neis` 계약 차이가 문서에 명시된다.
- `node scripts/korean_character_count.js --help` 가 동작한다.
- 혼합 한국어/영문/공백/개행/emoji 입력에 대한 테스트가 있다.
## Notes
- Unicode grapheme clusters: https://www.unicode.org/reports/tr29/
- WHATWG Encoding Standard: https://encoding.spec.whatwg.org/
- Node `Buffer.byteLength`: https://nodejs.org/api/buffer.html

View file

@ -0,0 +1,268 @@
#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
const LINE_BREAK_PATTERN = /\r\n|[\n\r\u2028\u2029]/gu;
const HANGUL_OR_MARK_PATTERN = /^[\p{Script=Hangul}\p{Mark}]+$/u;
const HAS_HANGUL_PATTERN = /\p{Script=Hangul}/u;
const WHITESPACE_ONLY_PATTERN = /^\s+$/u;
const ASCII_ONLY_PATTERN = /^[\x00-\x7F]+$/;
function ensureSegmenter() {
if (!globalThis.Intl?.Segmenter) {
throw new Error("Intl.Segmenter is required. Use Node.js 18 or newer.");
}
return new Intl.Segmenter("ko", { granularity: "grapheme" });
}
function segmentGraphemes(text) {
return Array.from(ensureSegmenter().segment(text), ({ segment }) => segment);
}
function countUtf8Bytes(text) {
return Buffer.byteLength(text, "utf8");
}
function countLines(text) {
if (text.length === 0) {
return 0;
}
return Array.from(text.matchAll(LINE_BREAK_PATTERN)).length + 1;
}
function countNeisBytes(text) {
let total = 0;
let lastIndex = 0;
for (const match of text.matchAll(LINE_BREAK_PATTERN)) {
const breakIndex = match.index ?? 0;
total += countNeisChunkBytes(text.slice(lastIndex, breakIndex));
total += 2;
lastIndex = breakIndex + match[0].length;
}
total += countNeisChunkBytes(text.slice(lastIndex));
return total;
}
function countNeisChunkBytes(chunk) {
return segmentGraphemes(chunk).reduce((sum, grapheme) => sum + countNeisGraphemeBytes(grapheme), 0);
}
function countNeisGraphemeBytes(grapheme) {
if (!grapheme) {
return 0;
}
if (ASCII_ONLY_PATTERN.test(grapheme)) {
return 1;
}
if (HANGUL_OR_MARK_PATTERN.test(grapheme) && HAS_HANGUL_PATTERN.test(grapheme)) {
return 3;
}
return countUtf8Bytes(grapheme);
}
function createReport(text, profile = "default") {
const graphemes = segmentGraphemes(text);
const bytesUtf8 = countUtf8Bytes(text);
const bytesNeis = countNeisBytes(text);
const selectedBytes = profile === "neis" ? bytesNeis : bytesUtf8;
return {
profile,
contract: {
characters: "Unicode extended grapheme clusters via Intl.Segmenter",
bytes:
profile === "neis"
? "NEIS-compatible bytes: Hangul grapheme=3B, ASCII grapheme=1B, each line break=2B, everything else falls back to UTF-8 bytes"
: "Actual UTF-8 encoded byte length",
lines:
"Empty string => 0 lines; otherwise count CRLF, LF, CR, U+2028, U+2029 as one line break each and add 1",
},
counts: {
characters: graphemes.length,
characters_without_whitespace: graphemes.filter((grapheme) => !WHITESPACE_ONLY_PATTERN.test(grapheme)).length,
code_points: Array.from(text).length,
utf16_code_units: text.length,
lines: countLines(text),
bytes: selectedBytes,
bytes_utf8: bytesUtf8,
bytes_neis: bytesNeis,
},
};
}
function parseArgs(argv, stdinIsTTY = process.stdin.isTTY) {
const options = {
format: "json",
inputMode: null,
profile: "default",
text: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
continue;
}
if (arg === "--text") {
setInputMode(options, "text");
options.text = readNextValue(argv, ++index, arg);
continue;
}
if (arg === "--file") {
setInputMode(options, "file");
options.file = readNextValue(argv, ++index, arg);
continue;
}
if (arg === "--stdin") {
setInputMode(options, "stdin");
continue;
}
if (arg === "--profile") {
const value = readNextValue(argv, ++index, arg);
if (!["default", "neis"].includes(value)) {
throw new Error(`Unknown profile: ${value}`);
}
options.profile = value;
continue;
}
if (arg === "--format") {
const value = readNextValue(argv, ++index, arg);
if (!["json", "text"].includes(value)) {
throw new Error(`Unknown format: ${value}`);
}
options.format = value;
continue;
}
throw new Error(`Unknown option: ${arg}`);
}
if (options.help) {
return options;
}
if (!options.inputMode) {
if (stdinIsTTY) {
throw new Error("Provide exactly one input source with --text, --file, or --stdin.");
}
options.inputMode = "stdin";
}
return options;
}
function setInputMode(options, nextMode) {
if (options.inputMode) {
throw new Error("Provide exactly one input source with --text, --file, or --stdin.");
}
options.inputMode = nextMode;
}
function readNextValue(argv, index, flagName) {
const value = argv[index];
if (value == null) {
throw new Error(`Missing value after ${flagName}`);
}
return value;
}
function readInput(options) {
if (options.inputMode === "text") {
return options.text ?? "";
}
if (options.inputMode === "file") {
return fs.readFileSync(options.file, "utf8");
}
return fs.readFileSync(0, "utf8");
}
function formatTextReport(report) {
return [
`profile: ${report.profile}`,
`characters: ${report.counts.characters}`,
`characters_without_whitespace: ${report.counts.characters_without_whitespace}`,
`code_points: ${report.counts.code_points}`,
`utf16_code_units: ${report.counts.utf16_code_units}`,
`lines: ${report.counts.lines}`,
`bytes: ${report.counts.bytes}`,
`bytes_utf8: ${report.counts.bytes_utf8}`,
`bytes_neis: ${report.counts.bytes_neis}`,
`character_contract: ${report.contract.characters}`,
`byte_contract: ${report.contract.bytes}`,
`line_contract: ${report.contract.lines}`,
].join("\n");
}
function printHelp() {
console.log(`Usage: node scripts/korean_character_count.js [--text <text> | --file <path> | --stdin] [--profile default|neis] [--format json|text]
Deterministically count Korean text characters, lines, and bytes.
Options:
--text <text> Count the provided text
--file <path> Read UTF-8 text from a file
--stdin Read UTF-8 text from stdin
--profile <name> default (grapheme + UTF-8) or neis
--format <name> json (default) or text
--help, -h Show this help text
`);
}
function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
printHelp();
return;
}
const report = createReport(readInput(options), options.profile);
const output = options.format === "text" ? formatTextReport(report) : JSON.stringify(report, null, 2);
console.log(output);
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = {
countLines,
countNeisBytes,
countUtf8Bytes,
createReport,
formatTextReport,
main,
parseArgs,
segmentGraphemes,
};

View file

@ -0,0 +1,89 @@
---
name: korean-patent-search
description: Search Korean patent and utility-model publications through the official KIPRIS Plus Open API with keyword search plus application-number detail lookup.
license: MIT
metadata:
category: ip
locale: ko-KR
phase: v1
---
# 한국 특허 정보 검색
## What this skill does
KIPRIS Plus(키프리스 플러스) 공식 Open API로 한국 특허/실용신안 공개·공고 데이터를 검색한다.
v1 범위:
- 키워드 검색 (`getWordSearch`)
- 출원번호 기준 서지 상세 조회 (`getBibliographyDetailInfoSearch`)
- 구조화된 JSON 출력
- 표준 `python3` helper 동봉
## When to use
- "배터리 관련 한국 특허 찾아줘"
- "출원번호 1020240001234 특허 요약 보여줘"
- "KIPRIS API로 특허 검색 결과를 JSON으로 받고 싶어"
- "출원인/IPC/초록까지 포함한 한국 특허 검색 결과가 필요해"
## Prerequisites
- 인터넷 연결
- `python3`
- KIPRIS Plus에서 발급받은 API 키
- helper 환경변수: `KIPRIS_PLUS_API_KEY`
- 실제 요청 쿼리 파라미터명: `ServiceKey`
- 설치된 skill payload 안에 `scripts/patent_search.py` helper 포함
## Inputs
- 키워드 검색
- 필수: `--query`
- 선택: `--year`
- 선택: `--page-no`
- 선택: `--num-rows`
- 선택: `--exclude-patent`
- 선택: `--exclude-utility`
- 상세 조회
- 필수: `--application-number`
## Workflow
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값도 helper가 한 번 정규화해서 그대로 받을 수 있다.
2. 키워드 검색이면 `getWordSearch` endpoint를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` endpoint를 호출한다.
4. XML 응답의 header/body/items 구조를 파싱한다.
5. 출원번호, 발명의명칭, 출원인, 초록, 공개/공고/등록 메타데이터를 JSON으로 정리한다.
## CLI examples
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
python3 scripts/patent_search.py --query "배터리" --year 2024 --num-rows 5
python3 scripts/patent_search.py --application-number 1020240001234
```
## Response policy
- 공식 KIPRIS Plus Open API 응답만 사용한다.
- 키가 없으면 `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 를 정확히 안내한다.
- 검색 결과는 최소한 출원번호, 발명의명칭, 출원일자, 출원인, 초록을 포함해 정리한다.
- 상세 조회는 `getBibliographyDetailInfoSearch` 기준으로 공개/공고/등록 메타데이터를 함께 정리한다.
- API 에러 코드는 숨기지 말고 그대로 surfaced 한다.
## Done when
- 유효한 ServiceKey로 `getWordSearch` 또는 `getBibliographyDetailInfoSearch` 호출이 가능하다.
- helper가 JSON을 출력한다.
- 에러 시 `KIPRIS_PLUS_API_KEY` / `ServiceKey` 관련 안내가 분명하다.
- 응답에 출원번호와 발명의명칭이 포함된다.
## Notes
- KIPRIS Plus 포털: `https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15058788/openapi.do`
- v1 helper는 `getWordSearch`, `getBibliographyDetailInfoSearch` 두 operation에 집중한다.
- 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 별도 심의 대상이다.

View file

@ -0,0 +1,409 @@
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass
from html.parser import HTMLParser
from typing import Callable
SERVICE_KEY_ENV_VAR = "KIPRIS_PLUS_API_KEY"
DEFAULT_TIMEOUT = 30
DEFAULT_NUM_ROWS = 10
DEFAULT_PAGE_NO = 1
BASE_API_URL = "https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice"
SEARCH_OPERATION = "getWordSearch"
DETAIL_OPERATION = "getBibliographyDetailInfoSearch"
DEFAULT_HEADERS = {
"Accept": "application/xml,text/xml;q=0.9,*/*;q=0.8",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
),
}
@dataclass(frozen=True)
class PatentSearchResult:
index_no: int | None
application_number: str
invention_title: str | None
register_status: str | None
application_date: str | None
open_number: str | None
open_date: str | None
publication_number: str | None
publication_date: str | None
register_number: str | None
register_date: str | None
ipc_number: str | None
abstract_text: str | None
applicant_name: str | None
drawing: str | None
big_drawing: str | None
@dataclass(frozen=True)
class PatentSearchResponse:
query: str
page_no: int
num_of_rows: int
total_count: int
items: list[PatentSearchResult]
@dataclass(frozen=True)
class PatentDetail:
application_number: str
invention_title: str | None
register_status: str | None
application_date: str | None
open_number: str | None
open_date: str | None
publication_number: str | None
publication_date: str | None
register_number: str | None
register_date: str | None
ipc_number: str | None
abstract_text: str | None
applicant_name: str | None
drawing: str | None
big_drawing: str | None
@dataclass
class XmlNode:
tag: str
children: list["XmlNode"]
text_chunks: list[str]
@property
def text(self) -> str:
return "".join(self.text_chunks)
class XmlNodeBuilder(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root: XmlNode | None = None
self.stack: list[XmlNode] = []
def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
node = XmlNode(tag=tag, children=[], text_chunks=[])
if self.stack:
self.stack[-1].children.append(node)
else:
self.root = node
self.stack.append(node)
def handle_endtag(self, tag: str) -> None: # type: ignore[override]
if self.stack:
self.stack.pop()
def handle_data(self, data: str) -> None: # type: ignore[override]
if self.stack:
self.stack[-1].text_chunks.append(data)
def clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = " ".join(value.split()).strip()
return cleaned or None
def parse_positive_int(raw_value: str) -> int:
value = int(raw_value)
if value <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return value
def resolve_service_key(explicit_key: str | None = None) -> str:
candidate = clean_text(explicit_key) or clean_text(os.getenv(SERVICE_KEY_ENV_VAR))
if candidate:
return urllib.parse.unquote(candidate)
raise ValueError(
f"missing {SERVICE_KEY_ENV_VAR}. Export {SERVICE_KEY_ENV_VAR} or pass --service-key "
"(mapped to the KIPRIS Plus ServiceKey query parameter)."
)
def build_operation_url(operation: str) -> str:
return f"{BASE_API_URL}/{operation}"
def build_search_params(
*,
query: str,
year: int | None = None,
page_no: int = DEFAULT_PAGE_NO,
num_of_rows: int = DEFAULT_NUM_ROWS,
patent: bool = True,
utility: bool = True,
service_key: str,
) -> dict[str, str]:
if not patent and not utility:
raise ValueError("At least one of patent or utility must remain enabled for keyword search.")
params = {
"word": query,
"patent": "true" if patent else "false",
"utility": "true" if utility else "false",
"pageNo": str(page_no),
"numOfRows": str(num_of_rows),
"ServiceKey": urllib.parse.unquote(service_key),
}
if year is not None:
params["year"] = str(year)
return params
def build_detail_params(*, application_number: str, service_key: str) -> dict[str, str]:
return {"applicationNumber": application_number, "ServiceKey": urllib.parse.unquote(service_key)}
def fetch_xml(url: str, params: dict[str, str], timeout: int = DEFAULT_TIMEOUT) -> str:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(request_url, headers=DEFAULT_HEADERS)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"KIPRIS Plus HTTP {exc.code}: {body or exc.reason}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Failed to reach KIPRIS Plus API: {exc.reason}") from exc
def normalize_tag(tag_name: str) -> str:
return tag_name.casefold()
def iter_children(element: ET.Element | XmlNode | None) -> list[ET.Element | XmlNode]:
if element is None:
return []
if isinstance(element, XmlNode):
return element.children
return list(element)
def find_child(element: ET.Element | XmlNode | None, tag_name: str) -> ET.Element | XmlNode | None:
normalized_tag = normalize_tag(tag_name)
for child in iter_children(element):
if normalize_tag(child.tag) == normalized_tag:
return child
return None
def find_children(element: ET.Element | XmlNode | None, tag_name: str) -> list[ET.Element | XmlNode]:
normalized_tag = normalize_tag(tag_name)
return [child for child in iter_children(element) if normalize_tag(child.tag) == normalized_tag]
def parse_xml_with_fallback(xml_text: str) -> XmlNode:
parser = XmlNodeBuilder()
try:
parser.feed(xml_text)
parser.close()
except Exception as exc: # pragma: no cover - defensive fallback guard
raise RuntimeError(f"Failed to parse KIPRIS Plus XML response: {exc}") from exc
if parser.root is None:
raise RuntimeError("Failed to parse KIPRIS Plus XML response: empty document")
return parser.root
def get_child_text(element: ET.Element | XmlNode | None, tag_name: str) -> str | None:
child = find_child(element, tag_name)
return clean_text(child.text if child is not None else None)
def parse_int(value: str | None) -> int | None:
if value is None:
return None
return int(value)
def parse_xml_response(xml_text: str) -> ET.Element | XmlNode:
try:
root = ET.fromstring(xml_text)
except (ET.ParseError, ImportError):
root = parse_xml_with_fallback(xml_text)
header = find_child(root, "header")
result_code = get_child_text(header, "resultCode")
result_msg = get_child_text(header, "resultMsg")
if result_code and result_code != "00":
raise RuntimeError(result_msg or f"KIPRIS Plus API error code {result_code}")
return root
def parse_patent_item(item: ET.Element | XmlNode) -> PatentSearchResult:
application_number = get_child_text(item, "applicationNumber")
if not application_number:
raise RuntimeError("KIPRIS Plus response item is missing applicationNumber")
return PatentSearchResult(
index_no=parse_int(get_child_text(item, "indexNo")),
application_number=application_number,
invention_title=get_child_text(item, "inventionTitle"),
register_status=get_child_text(item, "registerStatus"),
application_date=get_child_text(item, "applicationDate"),
open_number=get_child_text(item, "openNumber"),
open_date=get_child_text(item, "openDate"),
publication_number=get_child_text(item, "publicationNumber"),
publication_date=get_child_text(item, "publicationDate"),
register_number=get_child_text(item, "registerNumber"),
register_date=get_child_text(item, "registerDate"),
ipc_number=get_child_text(item, "ipcNumber"),
abstract_text=get_child_text(item, "astrtCont"),
applicant_name=get_child_text(item, "applicantName"),
drawing=get_child_text(item, "drawing"),
big_drawing=get_child_text(item, "bigDrawing"),
)
def parse_patent_search_response(xml_text: str, *, query: str) -> PatentSearchResponse:
root = parse_xml_response(xml_text)
body = find_child(root, "body")
items_parent = find_child(body, "items")
item_elements = find_children(items_parent, "item")
items = [parse_patent_item(item) for item in item_elements]
return PatentSearchResponse(
query=query,
page_no=parse_int(get_child_text(body, "pageNo")) or DEFAULT_PAGE_NO,
num_of_rows=parse_int(get_child_text(body, "numOfRows")) or len(items),
total_count=parse_int(get_child_text(body, "totalCount")) or len(items),
items=items,
)
def parse_patent_detail_response(xml_text: str) -> PatentDetail:
root = parse_xml_response(xml_text)
body = find_child(root, "body")
item = find_child(body, "item")
if item is None and body is not None:
items_parent = find_child(body, "items")
item = find_child(items_parent, "item")
if item is None:
raise RuntimeError("KIPRIS Plus detail response did not include an item payload")
search_item = parse_patent_item(item)
return PatentDetail(
application_number=search_item.application_number,
invention_title=search_item.invention_title,
register_status=search_item.register_status,
application_date=search_item.application_date,
open_number=search_item.open_number,
open_date=search_item.open_date,
publication_number=search_item.publication_number,
publication_date=search_item.publication_date,
register_number=search_item.register_number,
register_date=search_item.register_date,
ipc_number=search_item.ipc_number,
abstract_text=search_item.abstract_text,
applicant_name=search_item.applicant_name,
drawing=search_item.drawing,
big_drawing=search_item.big_drawing,
)
def search_patents(
query: str,
*,
year: int | None = None,
page_no: int = DEFAULT_PAGE_NO,
num_of_rows: int = DEFAULT_NUM_ROWS,
patent: bool = True,
utility: bool = True,
service_key: str | None = None,
fetcher: Callable[[str, dict[str, str], int], str] = fetch_xml,
timeout: int = DEFAULT_TIMEOUT,
) -> PatentSearchResponse:
key = resolve_service_key(service_key)
xml_text = fetcher(
build_operation_url(SEARCH_OPERATION),
build_search_params(
query=query,
year=year,
page_no=page_no,
num_of_rows=num_of_rows,
patent=patent,
utility=utility,
service_key=key,
),
timeout,
)
return parse_patent_search_response(xml_text, query=query)
def get_patent_detail(
application_number: str,
*,
service_key: str | None = None,
fetcher: Callable[[str, dict[str, str], int], str] = fetch_xml,
timeout: int = DEFAULT_TIMEOUT,
) -> PatentDetail:
key = resolve_service_key(service_key)
xml_text = fetcher(
build_operation_url(DETAIL_OPERATION),
build_detail_params(application_number=application_number, service_key=key),
timeout,
)
return parse_patent_detail_response(xml_text)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Search Korean patent information via the official KIPRIS Plus Open API."
)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--query", help="Keyword for KIPRIS getWordSearch")
mode.add_argument("--application-number", help="Application number for bibliography detail lookup")
parser.add_argument("--year", type=parse_positive_int, help="Optional year filter for keyword search")
parser.add_argument("--page-no", type=parse_positive_int, default=DEFAULT_PAGE_NO, help="Response page number")
parser.add_argument("--num-rows", type=parse_positive_int, default=DEFAULT_NUM_ROWS, help="Rows per page")
parser.add_argument("--service-key", help=f"KIPRIS Plus ServiceKey (defaults to ${SERVICE_KEY_ENV_VAR})")
parser.add_argument("--exclude-patent", action="store_true", help="Exclude patent results from keyword search")
parser.add_argument("--exclude-utility", action="store_true", help="Exclude utility-model results from keyword search")
parser.add_argument("--timeout", type=parse_positive_int, default=DEFAULT_TIMEOUT, help="HTTP timeout seconds")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.query:
payload = search_patents(
args.query,
year=args.year,
page_no=args.page_no,
num_of_rows=args.num_rows,
patent=not args.exclude_patent,
utility=not args.exclude_utility,
service_key=args.service_key,
timeout=args.timeout,
)
else:
payload = get_patent_detail(
args.application_number,
service_key=args.service_key,
timeout=args.timeout,
)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
print(json.dumps(asdict(payload), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View file

@ -0,0 +1,202 @@
---
name: korean-stock-search
description: Use k-skill-proxy to search Korean listed stocks, inspect KRX base information, and fetch daily trade snapshots without asking the user to issue a KRX API key.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# Korean Stock Search
## What this skill does
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-stock/...` 로 요청해서 KRX 상장 종목 검색, 종목 기본정보, 일별 시세를 조회한다.
upstream 설계 참고는 [`jjlabsio/korea-stock-mcp`](https://github.com/jjlabsio/korea-stock-mcp) 이지만, 사용자는 `KRX_API_KEY` 를 발급받거나 로컬 MCP 서버를 설치할 필요가 없다. `KRX_API_KEY` 는 proxy 서버에서만 관리한다.
## When to use
- "삼성전자 종목코드랑 시장구분 찾아줘"
- "005930 기본정보 보여줘"
- "SK하이닉스 20260404 종가/거래량 알려줘"
- "KOSDAQ 에서 알테오젠 시세 확인해줘"
## When not to use
- 미국/일본/가상자산 같은 비한국 주식 조회
- 실시간 체결/호가/분봉 조회
- 재무제표/공시 원문 분석 (이 스킬 범위 밖)
- 투자 자문/매수 추천
## Inputs
- `q`: 종목명 또는 종목코드 검색어 (`search` endpoint)
- `market`: `KOSPI` | `KOSDAQ` | `KONEX`
- `code`: 종목코드 (보통 6자리 단축코드, 예: `005930`)
- `bas_dd`: 기준일 `YYYYMMDD` (없으면 KST 오늘 날짜 기본값, 휴장일이면 최근 영업일로 다시 시도)
- `limit`: 검색 결과 수 (기본 10, 최대 20)
## Prerequisites
없음. 사용자는 `KRX_API_KEY` 를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
## Default path
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Supported endpoints
### 종목 검색
```http
GET /v1/korean-stock/search?q={검색어}&bas_dd={YYYYMMDD}
```
### 종목 기본정보
```http
GET /v1/korean-stock/base-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
```
### 종목 일별 시세
```http
GET /v1/korean-stock/trade-info?market={KOSPI|KOSDAQ|KONEX}&code={종목코드}&bas_dd={YYYYMMDD}
```
## Example requests
종목 검색:
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
종목 기본정보:
```bash
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'
```
종목 일별 시세:
```bash
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'
```
## Response shape
### 검색 응답
```json
{
"items": [
{
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"name": "삼성전자",
"short_name": "삼성전자",
"english_name": "Samsung Electronics",
"listed_at": "1975-06-11"
}
],
"query": { "q": "삼성전자", "bas_dd": "20260404", "limit": 10 },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 기본정보 응답
```json
{
"item": {
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"name": "삼성전자",
"short_name": "삼성전자",
"english_name": "Samsung Electronics",
"security_group": "주권",
"section_type": "대형주",
"stock_certificate_type": "보통주",
"par_value": 100,
"listed_shares": 5969782550
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
### 일별 시세 응답
```json
{
"item": {
"market": "KOSPI",
"code": "005930",
"standard_code": "KR7005930003",
"base_date": "20260404",
"name": "삼성전자",
"close_price": 84000,
"change_price": 1000,
"fluctuation_rate": 1.2,
"open_price": 83000,
"high_price": 84500,
"low_price": 82800,
"trading_volume": 12345678,
"trading_value": 1030000000000,
"market_cap": 500000000000000
},
"query": { "market": "KOSPI", "code": "005930", "bas_dd": "20260404" },
"proxy": { "name": "k-skill-proxy", "cache": { "hit": false, "ttl_ms": 300000 } }
}
```
## Response policy
- 종목명이 모호하면 먼저 `search` 로 시장/종목코드를 좁힌 뒤 `base-info` 또는 `trade-info` 로 들어간다.
- `trade-info` 결과는 일별 snapshot 이다. 실시간 호가/체결처럼 말하지 않는다.
- 휴장일/장마감 이전이면 해당 `bas_dd` 에 데이터가 없을 수 있으니 최근 영업일로 재시도한다.
- 숫자는 사람이 읽기 쉬운 단위(원, 주, 억/조)로 짧게 풀어주되 원본 숫자도 유지한다.
- 답변 말미에 "KRX 공식 데이터 기준 / 투자 조언 아님" 을 짧게 남긴다.
## Keep the answer compact
- 종목명 / 시장 / 종목코드
- 기준일
- 종가 / 등락률 / 거래량 / 시가총액
- 필요할 때만 상장일 / 상장주식수 / 액면가
- 여러 후보가 나오면 상위 3~5개만 보여주고 사용자가 고르게 한다
## Failure modes
- `q`, `market`, `code`, `bas_dd` 형식이 잘못되면 400 응답
- 프록시 서버에 `KRX_API_KEY` 가 없으면 503 응답
- upstream KRX 응답 오류면 502 응답
- 해당 기준일/시장에 종목이 없으면 404 `not_found`
## Done when
- 검색어가 모호하면 `search` 로 후보를 먼저 좁혔다.
- 필요한 경우 `base-info``trade-info` 를 호출해 핵심 수치를 정리했다.
- 사용자가 `KRX_API_KEY` 없이도 조회 가능하다는 점을 유지했다.
- KRX 공식 데이터 기준임을 짧게 남겼다.
## Notes
- 원본 참고: `https://github.com/jjlabsio/korea-stock-mcp`
- 공식 데이터 출처: KRX Open API (`https://openapi.krx.co.kr/contents/OPP/MAIN/main/index.cmd`)
- 이 스킬은 read-only 조회 전용이다.

View file

@ -0,0 +1,135 @@
---
name: market-kurly-search
description: 로그인 없이 접근 가능한 마켓컬리 검색/상품 상세 표면으로 상품 후보, 현재 가격, 할인 여부, 품절 여부를 조회한다.
license: MIT
metadata:
category: retail
locale: ko-KR
phase: v1
---
# Market Kurly Search
## What this skill does
마켓컬리 웹앱이 실제로 사용하는 **비로그인 검색/상품 상세 표면**을 사용해 아래 흐름을 처리한다.
- 키워드로 상품 후보를 검색한다.
- 현재 가격과 할인 여부를 확인한다.
- 품절 여부와 배송 타입을 확인한다.
- 상품 링크를 함께 반환한다.
- **주문/장바구니 같은 액션은 하지 않는다. 조회형으로만 답한다.**
## When to use
- "마켓컬리에서 우유 얼마야?"
- "컬리에서 딸기 검색해줘"
- "이 상품 품절인지 보고 링크도 줘"
- "지금 컬리 가격만 빠르게 보고 싶어"
## When not to use
- 주문/장바구니/결제까지 자동화해야 하는 경우
- 주소 기반 배송 가능 여부나 회원 전용 가격을 확정해야 하는 경우
- 로그인 세션이 필요한 개인화 추천/찜 정보를 조회해야 하는 경우
## Prerequisites
- 인터넷 연결
- `node` 18+
- 이 저장소의 `market-kurly-search` package 또는 동일 로직
## Required inputs
### 1. Ask for a product keyword if it is missing
상품명 또는 검색어가 없으면 먼저 물어본다.
- 권장 질문: `찾을 마켓컬리 상품명이나 검색어를 알려주세요. 예: 우유, 딸기, 닭가슴살`
- 너무 넓으면: `검색어가 너무 넓어요. 브랜드나 용량까지 같이 알려주시면 가격 후보를 더 정확히 추릴 수 있어요.`
### 2. Confirm which candidate they want when the query is ambiguous
검색 결과가 여러 개면 상위 2~3개만 보여주고 다시 확인받는다.
- 권장 질문: `후보가 여러 개예요. 아래 상품 중 어떤 상품 가격을 볼까요?`
- 응답에는 상품명 + 현재 가격 + 품절 여부 + 링크를 같이 붙인다.
## Official Market Kurly surfaces
- search list: `https://api.kurly.com/search/v4/sites/market/normal-search?keyword=<keyword>&page=1`
- search count: `https://api.kurly.com/search/v3/sites/market/normal-search/count?keyword=<keyword>&filters=&allow_replace=true`
- product detail page: `https://www.kurly.com/goods/<productNo>`
## Workflow
### 1. Search by keyword first
```js
const { searchProducts } = require("market-kurly-search")
const result = await searchProducts("우유")
console.log(result.items.slice(0, 3))
```
검색 결과에서는 아래 필드를 우선 본다.
- 상품명
- 현재 가격 (`discountedPrice` 우선, 없으면 `salesPrice`)
- 할인율
- 품절 여부
- 배송 타입
- 상품 링크
### 2. Use the count endpoint when the result set is broad
```js
const { countProducts } = require("market-kurly-search")
const count = await countProducts("우유")
console.log(count)
```
후보가 너무 많으면 `count` 를 먼저 보여 주고 검색어를 좁히라고 안내한다.
### 3. Use the goods page detail as a fallback or follow-up lookup
```js
const { getProductDetail } = require("market-kurly-search")
const detail = await getProductDetail(5063110)
console.log(detail)
```
`goods/<productNo>` HTML 안의 `__NEXT_DATA__` 에서 상품명, 가격, 품절 여부, 배송 타입을 추출한다.
### 4. Respond conservatively
응답은 짧고 보수적으로 정리한다.
- 상품명
- 현재 가격
- 필요하면 원가/할인가 여부
- 품절 여부 또는 판매 가능 여부
- 상품 링크
- **가격/품절/노출 정보는 시점에 따라 달라질 수 있으니 조회 시각 기준 참고값이라고 분명히 말한다.**
## Done when
- 상품 키워드를 확인했다.
- 검색 결과에서 후보와 현재 가격을 최소 1개 이상 반환했다.
- 필요하면 상품 상세 페이지로 보조 확인했다.
- 주문/장바구니 같은 범위 밖 액션은 하지 않았다.
## Failure modes
- 검색어가 너무 넓으면 후보가 과도하게 많아질 수 있다.
- 가격/품절/배송 문구는 시점에 따라 달라질 수 있다.
- 현재 확인한 표면은 **공식 개발자 Open API가 아니라 웹이 쓰는 공개 표면** 이므로 스키마가 바뀌면 깨질 수 있다.
- 회원 전용/주소 전용 정보는 비로그인 조회만으로 확정할 수 없다.
## Notes
- 조회형 스킬이다.
- 비로그인 공개 표면 우선 원칙을 유지한다.
- 주문/장바구니/로그인 요구 기능은 시도하지 않는다.

90
mfds-drug-safety/SKILL.md Normal file
View file

@ -0,0 +1,90 @@
---
name: mfds-drug-safety
description: 식약처 공공 OpenAPI로 의약품 안전정보를 조회하기 전에 증상·복용상황을 반드시 되묻는 인터뷰형 의약품 안전 체크 스킬.
license: MIT
metadata:
category: public-health
locale: ko-KR
phase: v1
---
# 의약품 안전 체크
## What this skill does
식약처 공식 OpenAPI를 사용해 **의약품개요정보(e약은요)** 와 **안전상비의약품 정보**를 조회한다.
하지만 사용자가 증상이나 복용 상황을 말하면 **바로 단정하지 말고 먼저 되묻는다.**
- 본인/아이/임산부/고령자 여부
- 어떤 약을 이미 먹었는지 / 지금 먹으려는지
- 언제부터 얼마나 복용했는지
- 현재 증상, 기저질환, 알레르기, 복용 중인 다른 약
- red flag (`호흡곤란`, `의식저하`, `심한 발진`, `지속되는 구토/흉통`)
red flag 가 있으면 API 조회보다 **즉시 119·응급실·의료진 연결**을 우선한다.
## When to use
- "이 약이랑 이 약 같이 먹어도 되니?"
- "타이레놀 먹는 중인데 판콜 같이 먹어도 돼?"
- "두드러기가 있는데 이 약 계속 먹어도 되나?"
- "식약처 공식 약 정보로 효능/주의사항 확인해줘"
## Prerequisites
- 인터넷 연결
- `python3`
- 공공데이터포털 식약처 API 활용승인 후 발급된 `DATA_GO_KR_API_KEY`
- 설치된 skill payload 안에 `scripts/mfds_drug_safety.py` helper 포함
## Mandatory interview first
증상/복용상황이 언급되면 바로 결론을 말하지 말고 먼저 되묻는다.
권장 첫 질문 예시:
- `누가 복용하려는지(본인/아이/임산부/고령자), 이미 먹은 약 이름, 언제 얼마나 복용했는지, 지금 있는 증상을 먼저 알려주세요.`
- `호흡곤란, 의식저하, 입술·혀 붓기, 심한 전신 발진이 있으면 즉시 119 또는 응급실로 가야 합니다.`
## Official surfaces
- 공공데이터포털 문서: `https://www.data.go.kr/data/15075057/openapi.do`
- e약은요 endpoint: `https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15097208/openapi.do`
- 안전상비의약품 endpoint: `https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq`
## Workflow
1. 증상/복용상황이 있으면 인터뷰를 먼저 진행한다.
2. red flag 가 하나라도 있으면 즉시 응급 안내로 전환한다.
3. 약 이름이 확인되면 `DrbEasyDrugInfoService/getDrbEasyDrugList``SafeStadDrugService/getSafeStadDrugInq` 로 공식 정보를 조회한다.
4. 효능, 사용법, 주의사항, 상호작용, 이상반응, 보관법을 짧게 정리한다.
5. `같이 먹어도 되나?` 질문에는 공식 상호작용 문구만 근거로 제시하고, 최종 판단은 약사·의료진 확인이 필요하다고 명시한다.
## CLI examples
```bash
python3 scripts/mfds_drug_safety.py interview \
--question "타이레놀이랑 판콜 같이 먹어도 되나요?" \
--symptoms "두드러기와 어지러움"
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_drug_safety.py lookup --item-name "타이레놀" --item-name "판콜"
```
## Response policy
- 이 스킬은 **진단/처방/복용 지시**를 하지 않는다.
- 공식 문서에 있는 효능/주의/상호작용 문구만 근거로 요약한다.
- 상호작용 문구가 모호하거나 red flag 가 있으면 약사·의사 상담으로 넘긴다.
- 증상이 있는 질문은 인터뷰 없이 바로 답하지 않는다.
## Done when
- 증상 또는 복용상황을 먼저 되물었다.
- red flag 여부를 확인했다.
- `DATA_GO_KR_API_KEY` 가 준비된 경우 공식 endpoint 조회 결과를 JSON으로 정리했다.
- 최소한 제품명, 업체명, 효능/주의/상호작용이 포함된 요약을 제공했다.

View file

@ -0,0 +1,204 @@
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html import unescape
from typing import Any
DRUG_EASY_ENDPOINT = "https://apis.data.go.kr/1471000/DrbEasyDrugInfoService/getDrbEasyDrugList"
SAFE_STAD_ENDPOINT = "https://apis.data.go.kr/1471000/SafeStadDrugService/getSafeStadDrugInq"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, url: str | None = None):
super().__init__(message)
self.status_code = status_code
self.url = url
def summarize_text(value: Any) -> str:
if value is None:
return ""
text = unescape(str(value))
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def resolve_service_key(explicit_key: str | None, env: dict[str, str] | None = None) -> str:
env = env or os.environ
candidate = explicit_key or env.get("DATA_GO_KR_API_KEY")
if not candidate:
raise ValueError("DATA_GO_KR_API_KEY 또는 --service-key 가 필요합니다.")
return urllib.parse.unquote(str(candidate).strip())
def build_drug_interview(question: str | None = None, symptoms: str | None = None) -> dict[str, Any]:
return {
"domain": "drug",
"question": summarize_text(question),
"symptoms": summarize_text(symptoms),
"must_ask": [
"누가 복용하려는지 알려주세요. (본인/아이/임산부/고령자)",
"무슨 약을 이미 먹었거나 지금 먹으려는지, 제품명/성분명을 각각 알려주세요.",
"언제부터, 얼마나 자주, 한 번에 얼마나 복용했는지 알려주세요.",
"지금 있는 증상과 언제 시작됐는지 알려주세요.",
"복용 중인 약, 기저질환, 알레르기 여부를 알려주세요.",
],
"red_flags": [
"호흡곤란 또는 숨쉬기 힘듦",
"의식저하, 실신, 혼동",
"입술·혀 붓기 또는 심한 전신 발진",
"지속되는 구토, 경련, 심한 흉통",
],
"urgent_action": "red flag 가 하나라도 있으면 약 정보 조회보다 즉시 119·응급실·의료진 연결을 우선하세요.",
"policy": "이 helper 는 진단이나 복용 지시를 하지 않고, 공식 식약처 안전정보 확인 전에 반드시 되묻기 흐름을 제공합니다.",
}
EASY_FIELD_MAP = {
"item_name": "itemName",
"company_name": "entpName",
"efficacy": "efcyQesitm",
"how_to_use": "useMethodQesitm",
"warnings": "atpnWarnQesitm",
"cautions": "atpnQesitm",
"interactions": "intrcQesitm",
"side_effects": "seQesitm",
"storage": "depositMethodQesitm",
"item_seq": "itemSeq",
}
SAFE_STAD_FIELD_MAP = {
"item_name": "PRDLST_NM",
"company_name": "BSSH_NM",
"efficacy": "EFCY_QESITM",
"how_to_use": "USE_METHOD_QESITM",
"warnings": "ATPN_WARN_QESITM",
"cautions": "ATPN_QESITM",
"interactions": "INTRC_QESITM",
"side_effects": "SE_QESITM",
}
def normalize_easy_drug_item(item: dict[str, Any]) -> dict[str, Any]:
normalized = {key: summarize_text(item.get(source_key)) for key, source_key in EASY_FIELD_MAP.items()}
normalized["source"] = "drug_easy_info"
return normalized
def normalize_safe_stad_item(item: dict[str, Any]) -> dict[str, Any]:
normalized = {key: summarize_text(item.get(source_key)) for key, source_key in SAFE_STAD_FIELD_MAP.items()}
normalized["source"] = "safe_standby_medicine"
return normalized
def _extract_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
body = payload.get("body") or {}
items = body.get("items") or {}
raw = items.get("item")
if raw is None:
return []
if isinstance(raw, list):
return [item for item in raw if isinstance(item, dict)]
if isinstance(raw, dict):
return [raw]
return []
def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
query = urllib.parse.urlencode({key: value for key, value in params.items() if value not in (None, "")})
request = urllib.request.Request(f"{url}?{query}", headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise ApiError(f"MFDS request failed with HTTP {error.code}", status_code=error.code, url=request.full_url) from error
def lookup_drugs(
item_names: list[str],
*,
service_key: str,
limit: int = 5,
request_json: Any = _request_json,
) -> dict[str, Any]:
normalized_items: list[dict[str, Any]] = []
for item_name in item_names:
easy_payload = request_json(
DRUG_EASY_ENDPOINT,
{
"ServiceKey": service_key,
"pageNo": 1,
"numOfRows": limit,
"type": "json",
"itemName": item_name,
},
)
easy_items = [normalize_easy_drug_item(item) for item in _extract_items(easy_payload)]
safe_payload = request_json(
SAFE_STAD_ENDPOINT,
{
"serviceKey": service_key,
"pageNo": 1,
"numOfRows": limit,
"type": "json",
"PRDLST_NM": item_name,
},
)
safe_items = [normalize_safe_stad_item(item) for item in _extract_items(safe_payload)]
normalized_items.extend(easy_items)
normalized_items.extend(safe_items)
return {
"query": {"item_names": item_names, "limit": limit},
"items": normalized_items,
"note": "상호작용 문구는 공식 품목 안내를 그대로 요약한 참고 정보이며, 복용 가능 여부의 최종 판단은 약사·의료진 확인이 필요합니다.",
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="MFDS drug-safety helper")
subparsers = parser.add_subparsers(dest="command", required=True)
interview = subparsers.add_parser("interview", help="print the mandatory symptom follow-up interview")
interview.add_argument("--question", default="")
interview.add_argument("--symptoms", default="")
lookup = subparsers.add_parser("lookup", help="look up official MFDS drug safety records")
lookup.add_argument("--item-name", action="append", required=True)
lookup.add_argument("--service-key")
lookup.add_argument("--limit", type=int, default=5)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "interview":
print(json.dumps(build_drug_interview(question=args.question, symptoms=args.symptoms), ensure_ascii=False, indent=2))
return 0
if args.command == "lookup":
try:
service_key = resolve_service_key(args.service_key)
payload = lookup_drugs(args.item_name, service_key=service_key, limit=args.limit)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
raise SystemExit(main())

98
mfds-food-safety/SKILL.md Normal file
View file

@ -0,0 +1,98 @@
---
name: mfds-food-safety
description: 식약처/식품안전나라 공개 표면으로 식품 회수·부적합 정보를 조회하기 전에 증상·섭취상황을 반드시 되묻는 인터뷰형 식품 안전 체크 스킬.
license: MIT
metadata:
category: public-health
locale: ko-KR
phase: v1
---
# 식품 안전 체크
## What this skill does
식약처/식품안전나라 공개 표면으로 **부적합 식품 목록**과 **회수·판매중지 공개 목록**을 확인한다.
하지만 사용자가 복통, 설사, 발진 같은 증상을 말하면 **바로 단정하지 말고 먼저 되묻는다.**
- 누가 먹었는지 (본인/아이/임산부/고령자)
- 무엇을 언제 얼마나 먹었는지
- 같이 먹은 음식/술/약
- 현재 증상과 시작 시점
- 기저질환, 임신, 알레르기
- red flag (`혈변`, `탈수`, `호흡곤란`, `의식저하`, `심한 복통/고열`)
red flag 가 있으면 식품 조회보다 **즉시 응급실·119·의료진 안내**가 우선이다.
## When to use
- "이 음식 먹어도 괜찮니?"
- "이 김밥 먹고 배가 아픈데 회수 이력 있나?"
- "식약처 공식 부적합 식품 목록에서 제품명 확인해줘"
- "식품안전나라 공개 회수 목록에서 업체명으로 찾아줘"
## Prerequisites
- 인터넷 연결
- `python3`
- 부적합 식품 live 조회용 `DATA_GO_KR_API_KEY` (공공데이터포털)
- 회수정보 smoke/demo 용 `--sample-recalls` 또는 식품안전나라 API key
- 설치된 skill payload 안에 `scripts/mfds_food_safety.py` helper 포함
## Mandatory interview first
증상/섭취상황이 언급되면 결론을 말하기 전에 먼저 되묻는다.
권장 첫 질문 예시:
- `누가 무엇을 언제 얼마나 먹었는지, 지금 복통/구토/설사/발진 같은 증상이 있는지 먼저 알려주세요.`
- `호흡곤란, 혈변, 심한 탈수, 의식저하, 심한 복통/고열이 있으면 즉시 응급실이나 119가 우선입니다.`
## Official surfaces
- 공공데이터포털 문서: `https://www.data.go.kr/data/15056516/openapi.do`
- 부적합 식품 endpoint: `https://apis.data.go.kr/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01`
- 식품안전나라 회수·판매중지 문서: `https://www.data.go.kr/data/15074318/openapi.do`
- 식품안전나라 API 안내: `https://www.foodsafetykorea.go.kr/api/openApiInfo.do?menu_grp=MENU_GRP31&menu_no=661&show_cnt=10&start_idx=1&svc_no=I0490&svc_type_cd=API_TYPE06`
- 식품안전나라 회수 sample: `https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/1/5`
## Workflow
1. 증상/섭취상황이 있으면 인터뷰를 먼저 진행한다.
2. red flag 가 있으면 즉시 응급 안내로 전환한다.
3. `PrsecImproptFoodInfoService03/getPrsecImproptFoodList01` 로 부적합 식품 목록을 가져와 제품명/업체명 기준으로 로컬 필터링한다.
4. 필요하면 식품안전나라 `I0490` 회수 sample/live 목록도 함께 확인한다.
5. 제품명, 업체명, 회수/부적합 사유, 공개일자를 짧게 정리하고, 먹어도 되는지 단정하지 않는다.
## CLI examples
```bash
python3 scripts/mfds_food_safety.py interview \
--question "이 김밥 먹어도 되나요?" \
--symptoms "복통과 설사"
```
```bash
python3 scripts/mfds_food_safety.py search --query "김밥" --sample-recalls --limit 5
```
```bash
export DATA_GO_KR_API_KEY=your-service-key
python3 scripts/mfds_food_safety.py search --query "김밥" --limit 5
```
## Response policy
- 이 스킬은 **직접 진단**을 하지 않는다.
- 이 스킬은 **식중독 진단**이나 **섭취 허가/금지의 최종 판정**을 하지 않는다.
- 공식 공개 목록에 있는 사실만 전달한다.
- 증상이 있는 질문은 인터뷰 없이 바로 답하지 않는다.
- red flag 또는 고위험군이면 의료진 상담을 우선 권고한다.
## Done when
- 증상 또는 섭취상황을 먼저 되물었다.
- red flag 여부를 확인했다.
- 공식 공개 목록에서 제품명 또는 업체명 기준 결과를 최소 1건 이상 찾았거나, 없다고 분명히 알렸다.
- 제품명, 업체명, 공개사유/부적합 사유, 공개일자를 포함한 요약을 제공했다.

View file

@ -0,0 +1,279 @@
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from html import unescape
from typing import Any
IMPROPER_FOOD_ENDPOINT = "https://apis.data.go.kr/1471000/PrsecImproptFoodInfoService03/getPrsecImproptFoodList01"
FOOD_RECALL_SAMPLE_URL = "https://openapi.foodsafetykorea.go.kr/api/sample/I0490/json/{start}/{end}"
FOOD_RECALL_LIVE_URL = "https://openapi.foodsafetykorea.go.kr/api/{api_key}/I0490/json/{start}/{end}"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, url: str | None = None):
super().__init__(message)
self.status_code = status_code
self.url = url
def summarize_text(value: Any) -> str:
if value is None:
return ""
text = unescape(str(value))
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def resolve_data_go_service_key(explicit_key: str | None, env: dict[str, str] | None = None) -> str:
env = env or os.environ
candidate = explicit_key or env.get("DATA_GO_KR_API_KEY")
if not candidate:
raise ValueError("DATA_GO_KR_API_KEY 또는 --service-key 가 필요합니다.")
return urllib.parse.unquote(str(candidate).strip())
def build_food_interview(question: str | None = None, symptoms: str | None = None) -> dict[str, Any]:
return {
"domain": "food",
"question": summarize_text(question),
"symptoms": summarize_text(symptoms),
"must_ask": [
"누가 먹었거나 먹으려는지 알려주세요. (본인/아이/임산부/고령자)",
"무엇을 언제 먹었는지, 얼마나 먹었는지 알려주세요.",
"같이 먹은 음식이나 술, 복용 중인 약이 있는지 알려주세요.",
"복통·구토·설사·발진 같은 증상이 언제부터 시작됐는지 알려주세요.",
"기저질환, 임신 여부, 알레르기 여부를 알려주세요.",
],
"red_flags": [
"호흡곤란, 입술·혀 붓기 같은 급성 알레르기 반응",
"혈변 또는 검은변",
"심한 탈수, 소변 감소, 계속되는 구토",
"의식저하, 고열, 심한 복통",
],
"urgent_action": "red flag 가 있으면 식품 조회보다 즉시 응급실·119·의료진 연결을 우선하세요.",
"policy": "이 helper 는 공식 식품 안전정보 조회 전에 반드시 되묻기 흐름을 제공하며, 먹어도 되는지 단정하지 않습니다.",
}
def normalize_food_recall_row(row: dict[str, Any]) -> dict[str, Any]:
return {
"source": "foodsafetykorea_recall",
"product_name": summarize_text(row.get("PRDLST_NM") or row.get("PRDTNM")),
"company_name": summarize_text(row.get("BSSH_NM") or row.get("BSSHNM")),
"reason": summarize_text(row.get("RTRVLPRVNS")),
"created_at": summarize_text(row.get("CRET_DTM")),
"distribution_deadline": summarize_text(row.get("DISTBTMLMT")),
"category": summarize_text(row.get("PRDLST_TYPE") or row.get("PRDLST_CD_NM")),
}
def normalize_improper_food_item(item: dict[str, Any]) -> dict[str, Any]:
reason_parts = [summarize_text(item.get("IMPROPT_ITM")), summarize_text(item.get("INSPCT_RESULT"))]
return {
"source": "mfds_improper_food",
"product_name": summarize_text(item.get("PRDUCT")),
"company_name": summarize_text(item.get("ENTRPS")),
"reason": "; ".join(part for part in reason_parts if part),
"created_at": summarize_text(item.get("REGIST_DT")),
"category": summarize_text(item.get("FOOD_TY")),
}
def filter_food_items(items: list[dict[str, Any]], query: str) -> list[dict[str, Any]]:
needle = summarize_text(query).casefold()
if not needle:
return items
product_matches = [
item for item in items if needle in summarize_text(item.get("product_name")).casefold()
]
if product_matches:
return product_matches
company_matches = [
item for item in items if needle in summarize_text(item.get("company_name")).casefold()
]
if company_matches:
return company_matches
return [
item for item in items if needle in summarize_text(item.get("reason")).casefold()
]
def _request_json(url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
full_url = url
if params:
full_url = f"{url}?{urllib.parse.urlencode({key: value for key, value in params.items() if value not in (None, '')})}"
request = urllib.request.Request(full_url, headers={"Accept": "application/json", "User-Agent": "k-skill-mfds/1.0"})
try:
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read().decode("utf-8", errors="replace")
try:
return json.loads(body)
except json.JSONDecodeError as error:
hostname = (urllib.parse.urlparse(request.full_url).hostname or "").casefold()
if hostname == "openapi.foodsafetykorea.go.kr":
raise ApiError(
"식품안전나라 응답이 JSON이 아닙니다. --foodsafetykorea-key 가 유효한지 확인하세요.",
url=request.full_url,
) from error
content_type = summarize_text(response.headers.get("Content-Type") or "unknown")
raise ApiError(
f"MFDS food response was not valid JSON (content-type: {content_type})",
url=request.full_url,
) from error
except urllib.error.HTTPError as error:
raise ApiError(f"MFDS food request failed with HTTP {error.code}", status_code=error.code, url=request.full_url) from error
def _extract_improper_food_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
body = payload.get("body") or {}
items = body.get("items") or {}
raw = items.get("item")
if raw is None:
return []
if isinstance(raw, list):
return [item for item in raw if isinstance(item, dict)]
if isinstance(raw, dict):
return [raw]
return []
def _extract_food_recall_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
root = payload.get("I0490") or {}
rows = root.get("row")
if rows is None:
return []
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
if isinstance(rows, dict):
return [rows]
return []
def fetch_improper_food_items(service_key: str, *, limit: int = 100, request_json: Any = _request_json) -> list[dict[str, Any]]:
payload = request_json(
IMPROPER_FOOD_ENDPOINT,
{"ServiceKey": service_key, "pageNo": 1, "numOfRows": limit, "type": "json"},
)
return [normalize_improper_food_item(item) for item in _extract_improper_food_items(payload)]
def fetch_food_recall_rows(
*,
limit: int = 100,
sample: bool = False,
foodsafety_api_key: str | None = None,
request_json: Any = _request_json,
) -> list[dict[str, Any]]:
start = 1
end = max(limit, 1)
if sample or not foodsafety_api_key:
url = FOOD_RECALL_SAMPLE_URL.format(start=start, end=end)
else:
url = FOOD_RECALL_LIVE_URL.format(api_key=foodsafety_api_key, start=start, end=end)
payload = request_json(url)
return [normalize_food_recall_row(row) for row in _extract_food_recall_rows(payload)]
def search_food_safety(
query: str,
*,
service_key: str | None = None,
foodsafety_api_key: str | None = None,
sample_recalls: bool = False,
limit: int = 10,
request_json: Any = _request_json,
) -> dict[str, Any]:
items: list[dict[str, Any]] = []
warnings: list[str] = []
if service_key:
try:
items.extend(fetch_improper_food_items(service_key, limit=max(limit * 5, 50), request_json=request_json))
except ApiError as error:
warnings.append(str(error))
else:
warnings.append("DATA_GO_KR_API_KEY 가 없어 부적합 식품 live 조회는 건너뜁니다.")
if sample_recalls or foodsafety_api_key:
try:
items.extend(
fetch_food_recall_rows(
limit=max(limit * 5, 50),
sample=sample_recalls,
foodsafety_api_key=foodsafety_api_key,
request_json=request_json,
)
)
except ApiError as error:
warnings.append(str(error))
else:
warnings.append("식품안전나라 회수 정보는 --sample-recalls 또는 --foodsafetykorea-key 가 필요합니다.")
filtered = filter_food_items(items, query)[:limit]
return {
"query": query,
"items": filtered,
"warnings": warnings,
"note": "이 결과는 공식 회수·부적합 공개 목록 기반 참고 정보이며, 먹어도 되는지의 최종 판단은 증상 인터뷰와 의료진 상담이 우선입니다.",
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="MFDS food-safety helper")
subparsers = parser.add_subparsers(dest="command", required=True)
interview = subparsers.add_parser("interview", help="print the mandatory symptom follow-up interview")
interview.add_argument("--question", default="")
interview.add_argument("--symptoms", default="")
search = subparsers.add_parser("search", help="search official food recall/improper food records")
search.add_argument("--query", required=True)
search.add_argument("--service-key")
search.add_argument("--foodsafetykorea-key")
search.add_argument("--sample-recalls", action="store_true")
search.add_argument("--limit", type=int, default=10)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "interview":
print(json.dumps(build_food_interview(question=args.question, symptoms=args.symptoms), ensure_ascii=False, indent=2))
return 0
if args.command == "search":
try:
service_key = None
if args.service_key or os.environ.get("DATA_GO_KR_API_KEY"):
service_key = resolve_data_go_service_key(args.service_key)
payload = search_food_safety(
args.query,
service_key=service_key,
foodsafety_api_key=args.foodsafetykorea_key,
sample_recalls=args.sample_recalls,
limit=args.limit,
)
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
except ValueError as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
return 1
if __name__ == "__main__":
raise SystemExit(main())

47
package-lock.json generated
View file

@ -848,6 +848,10 @@
"dev": true,
"license": "ISC"
},
"node_modules/hipass-receipt": {
"resolved": "packages/hipass-receipt",
"link": true
},
"node_modules/human-id": {
"version": "4.1.3",
"dev": true,
@ -1046,6 +1050,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/market-kurly-search": {
"resolved": "packages/market-kurly-search",
"link": true
},
"node_modules/merge2": {
"version": "1.4.1",
"dev": true,
@ -1225,6 +1233,18 @@
"version": "7.1.0",
"license": "MIT"
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/prettier": {
"version": "2.8.8",
"dev": true,
@ -1620,15 +1640,14 @@
}
},
"packages/blue-ribbon-nearby": {
"version": "0.2.0",
"version": "0.2.1",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/cheap-gas-nearby": {
"version": "0.1.0",
"extraneous": true,
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1641,6 +1660,19 @@
"node": ">=18"
}
},
"packages/hipass-receipt": {
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"playwright-core": "^1.52.0"
},
"bin": {
"hipass-receipt": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/k-lotto": {
"version": "0.2.0",
"license": "MIT",
@ -1673,6 +1705,13 @@
}
},
"packages/lck-analytics": {
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/market-kurly-search": {
"version": "0.1.0",
"license": "MIT",
"engines": {
@ -1687,7 +1726,7 @@
}
},
"packages/used-car-price-search": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

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

View file

@ -0,0 +1,80 @@
# hipass-receipt
`hipass-receipt` 는 공식 하이패스 홈페이지(`https://www.hipass.co.kr`)에서 **사용자가 직접 로그인한 Chrome / Playwright 세션**을 재사용해 사용내역 조회와 영수증 발급 팝업 진입을 돕는 helper 입니다.
## Important scope limits
- 이 패키지는 **logged-in browser session only** 입니다.
- ID/PW/인증코드/OTP 를 자동 입력하지 않습니다.
- 세션이 만료되면 `/comm/lginpg.do` redirect 또는 `mgs_type 11/12` 를 감지하고 재로그인을 요구합니다.
- 장시간 무인 로그인 유지 봇은 지원하지 않습니다.
## Install
```bash
npm install hipass-receipt
```
이 패키지는 CDP 연결용 `playwright-core` 를 함께 설치한다. 별도 Playwright 브라우저 번들을 받지 않고, 사용자가 직접 띄운 기존 Chrome/Chromium 프로필에 연결한다.
## Start Chrome with a dedicated profile
```bash
hipass-receipt chrome-command --profile-dir "$HOME/.cache/k-skill/hipass-chrome" --debugging-port 9222
```
이 명령이 출력한 Chrome 실행문으로 브라우저를 띄우고, 사용자가 직접 `https://www.hipass.co.kr/comm/lginpg.do` 에 로그인합니다.
## List usage history
```bash
hipass-receipt list \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--page-size 30 \
--encrypted-card-number BASE64_OR_SITE_VALUE
```
내부적으로 `/usepculr/InitUsePculrTabSearch.do``hpForm` submit → `/usepculr/UsePculrTabSearchList.do` 흐름을 사용하고, iframe HTML을 정규화된 JSON으로 반환합니다.
`--encrypted-card-number` 는 기존 `--ecd-no` 별칭과 동일하게 동작합니다.
## Open a receipt popup for one row
먼저 `list` 결과에서 `rowIndex` 를 확인한 뒤 같은 검색 조건으로 `receipt` 를 호출합니다.
```bash
hipass-receipt receipt \
--cdp-url http://127.0.0.1:9222 \
--start-date 2026-04-01 \
--end-date 2026-04-07 \
--row-index 1
```
`receipt` 는 선택한 행 안에서 `영수증`/`출력` 텍스트를 가진 control 을 클릭하고, 팝업이 열리면 URL/title 을 반환합니다.
## Library helpers
- `buildUsageHistoryQuery()`
- `buildReceiptRequest()`
- `buildChromeLaunchCommand()`
- `buildUsageHistorySearchPayload()`
- `detectSessionState()`
- `inspectHipassPage()`
- `parseUsageHistoryList()`
- `findUsageHistoryEntry()`
- `listUsageHistory()`
- `openReceiptPopup()`
## Verification without credentials
fixture 기반 smoke test 는 다음처럼 실행할 수 있습니다.
repo workspace 또는 unpacked tarball/package 디렉터리 안에서는 아래 fixture smoke 를 바로 실행할 수 있습니다.
```bash
node src/cli.js fixture-demo --fixture test/fixtures/usage-history-list.html
```
실서비스 최종 검증은 **로그인된 세션에서 수동 smoke test** 가 필요합니다.

View file

@ -0,0 +1,39 @@
{
"name": "hipass-receipt",
"version": "0.1.0",
"description": "Hi-Pass logged-in browser-session helper for usage-history and receipt workflows",
"license": "MIT",
"main": "src/index.js",
"bin": {
"hipass-receipt": "src/cli.js"
},
"files": [
"src",
"test/fixtures",
"README.md"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/NomaDamas/k-skill.git"
},
"keywords": [
"k-skill",
"korea",
"hipass",
"receipt",
"playwright"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/browser.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
},
"dependencies": {
"playwright-core": "^1.52.0"
}
}

View file

@ -0,0 +1,229 @@
const fs = require("node:fs")
const path = require("node:path")
const {
HIPASS_ENDPOINTS,
USAGE_HISTORY_INIT_URL,
buildUsageHistoryQuery,
inspectHipassPage,
parseUsageHistoryList
} = require("./parse")
function resolveChromePath(explicitPath) {
if (explicitPath) {
return explicitPath
}
const candidates = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
"/Applications/Chromium.app/Contents/MacOS/Chromium"
]
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0]
}
function shellQuote(value) {
return `"${String(value).replace(/["\\$`]/g, "\\$&")}"`
}
function buildChromeLaunchCommand(options = {}) {
const chromePath = resolveChromePath(options.chromePath)
const profileDir = options.profileDir || path.join(process.env.HOME || "~", ".cache", "k-skill", "hipass-chrome")
const debuggingPort = Number(options.debuggingPort || 9222)
const extraArgs = Array.isArray(options.extraArgs) ? options.extraArgs : []
const args = [
`--user-data-dir=${shellQuote(profileDir)}`,
`--remote-debugging-port=${debuggingPort}`,
"--no-first-run",
"--no-default-browser-check",
...extraArgs,
HIPASS_ENDPOINTS.loginPage
]
return `${shellQuote(chromePath)} ${args.join(" ")}`
}
async function loadChromium() {
for (const moduleName of ["playwright-core", "playwright"]) {
try {
const loaded = require(moduleName)
if (loaded.chromium) {
return loaded.chromium
}
} catch {
// ignore and try the next module name
}
}
throw new Error(
"playwright-core or playwright is required for live browser-session automation. Install one of them in the environment that uses hipass-receipt.",
)
}
async function connectToChrome(options = {}) {
const chromium = await loadChromium()
return chromium.connectOverCDP(options.cdpUrl || "http://127.0.0.1:9222")
}
async function getAutomationPage(browser) {
const context = browser.contexts()[0] || (await browser.newContext())
const existingPage = context.pages()[0]
const page = existingPage || (await context.newPage())
return { context, page }
}
async function gotoUsageHistoryPage(page) {
await page.goto(USAGE_HISTORY_INIT_URL, { waitUntil: "domcontentloaded" })
const info = inspectHipassPage(await page.content())
if (info.reloginRequired) {
throw new Error("Hi-Pass session is not authenticated or has expired. Ask the user to log in again in the same Chrome profile.")
}
return info
}
async function submitUsageHistorySearch(page, query) {
await page.evaluate((submittedQuery) => {
const form = document.forms.hpForm || document.getElementById("hpForm")
if (!form) {
throw new Error("Expected the Hi-Pass usage-history page to expose form hpForm")
}
const setFieldValue = (name, value) => {
const element = form.elements.namedItem(name)
const stringValue = String(value)
if (!element) {
const hidden = document.createElement("input")
hidden.type = "hidden"
hidden.name = name
hidden.value = stringValue
form.appendChild(hidden)
return
}
if (typeof element.length === "number" && element.tagName == null) {
Array.from(element).forEach((candidate) => {
candidate.checked = candidate.value === stringValue
})
return
}
element.value = stringValue
}
Object.entries(submittedQuery).forEach(([name, value]) => setFieldValue(name, value))
form.submit()
}, query)
const frame = await waitForUsageHistoryFrame(page)
await frame.waitForLoadState("domcontentloaded").catch(() => {})
const html = await frame.content()
const info = inspectHipassPage(html)
if (info.reloginRequired) {
throw new Error("Hi-Pass session expired while loading the usage-history list. Ask the user to log in again.")
}
return { frame, html, info }
}
async function waitForUsageHistoryFrame(page) {
for (let attempt = 0; attempt < 40; attempt += 1) {
const frame = page.frames().find((candidate) => candidate.name() === "if_main_post")
if (frame && frame.url() !== "about:blank") {
return frame
}
await page.waitForTimeout(250)
}
throw new Error("Timed out waiting for the usage-history iframe (if_main_post) to load")
}
async function closeBrowserConnection(browser) {
if (!browser || typeof browser.close !== "function") {
return
}
await browser.close().catch(() => {})
}
async function listUsageHistory(options = {}) {
const browser = await connectToChrome(options)
try {
const { page } = await getAutomationPage(browser)
await gotoUsageHistoryPage(page)
const query = buildUsageHistoryQuery(options)
const { html } = await submitUsageHistorySearch(page, query)
return {
query,
...parseUsageHistoryList(html)
}
} finally {
await closeBrowserConnection(browser)
}
}
async function openReceiptPopup(options = {}) {
const browser = await connectToChrome(options)
try {
const { page, context } = await getAutomationPage(browser)
await gotoUsageHistoryPage(page)
const query = buildUsageHistoryQuery(options)
const { frame, html } = await submitUsageHistorySearch(page, query)
const parsed = parseUsageHistoryList(html)
const rowIndex = Number(options.rowIndex || 1)
const row = parsed.rows[rowIndex - 1]
if (!row) {
throw new Error(`Could not find usage-history row ${rowIndex}`)
}
const popupPromise = context.waitForEvent("page", { timeout: 5000 }).catch(() => null)
await frame.locator("table tbody tr").nth(rowIndex - 1).evaluate((element) => {
const clickable = [...element.querySelectorAll('a,button,input[type="button"],input[type="submit"]')].find((candidate) => {
const label = (candidate.innerText || candidate.textContent || candidate.value || "").trim()
return /영수증|출력/.test(label)
})
if (!clickable) {
throw new Error("Could not find a receipt button/link in the selected usage-history row")
}
clickable.click()
})
const popup = await popupPromise
if (!popup) {
return {
query,
entry: row,
popupUrl: null,
popupTitle: null,
popupCaptured: false
}
}
await popup.waitForLoadState("domcontentloaded").catch(() => {})
return {
query,
entry: row,
popupUrl: popup.url(),
popupTitle: await popup.title().catch(() => null),
popupCaptured: true
}
} finally {
await closeBrowserConnection(browser)
}
}
module.exports = {
buildChromeLaunchCommand,
connectToChrome,
listUsageHistory,
openReceiptPopup
}

View file

@ -0,0 +1,126 @@
#!/usr/bin/env node
const fs = require("node:fs")
const path = require("node:path")
const {
HIPASS_ENDPOINTS,
buildChromeLaunchCommand,
buildUsageHistoryQuery,
inspectHipassPage,
listUsageHistory,
openReceiptPopup,
parseUsageHistoryList
} = require("./index")
function printHelp() {
process.stdout.write(`hipass-receipt — logged-in browser session helper for https://www.hipass.co.kr
Commands:
hipass-receipt --help
hipass-receipt chrome-command [--profile-dir DIR] [--debugging-port PORT] [--chrome-path PATH]
hipass-receipt fixture-demo --fixture PATH [--start-date YYYY-MM-DD --end-date YYYY-MM-DD]
hipass-receipt list --start-date YYYY-MM-DD --end-date YYYY-MM-DD [--cdp-url URL] [--page-size N] [--ecd-no VALUE|--encrypted-card-number VALUE]
hipass-receipt receipt --start-date YYYY-MM-DD --end-date YYYY-MM-DD --row-index N [--cdp-url URL] [--ecd-no VALUE|--encrypted-card-number VALUE]
Notes:
- This workflow only supports a logged-in browser session. It does not automate ID/PW or OTP entry.
- Start Chrome with a dedicated --remote-debugging-port and --user-data-dir profile, then let the user log in manually.
- Hi-Pass exposes session timeout behavior through /comm/sessionCheck.do and permission-check redirects.
`)
}
function parseArgs(argv) {
const args = { _: [] }
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (!token.startsWith("--")) {
args._.push(token)
continue
}
const key = token.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase())
const value = argv[index + 1] && !argv[index + 1].startsWith("--") ? argv[++index] : true
args[key] = value
}
return args
}
function required(args, key, description) {
if (!args[key]) {
throw new Error(`Missing required --${description || key}`)
}
return args[key]
}
async function main() {
const argv = process.argv.slice(2)
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "help") {
printHelp()
return
}
const command = argv[0]
const args = parseArgs(argv.slice(1))
const ecdNo = args.ecdNo ?? args.encryptedCardNumber
if (command === "chrome-command") {
process.stdout.write(
`${buildChromeLaunchCommand({
chromePath: args.chromePath,
profileDir: args.profileDir,
debuggingPort: args.debuggingPort
})}\n`,
)
return
}
if (command === "fixture-demo") {
const fixturePath = path.resolve(required(args, "fixture", "fixture PATH"))
const html = fs.readFileSync(fixturePath, "utf8")
const output = {
endpoints: HIPASS_ENDPOINTS,
pageInfo: inspectHipassPage(html),
query: args.startDate && args.endDate ? buildUsageHistoryQuery(args) : null,
parsed: parseUsageHistoryList(html)
}
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`)
return
}
if (command === "list") {
const parsed = await listUsageHistory({
cdpUrl: args.cdpUrl,
startDate: required(args, "startDate", "start-date YYYY-MM-DD"),
endDate: required(args, "endDate", "end-date YYYY-MM-DD"),
ecdNo,
pageSize: args.pageSize,
pageNo: args.pageNo,
receiptTimeType: args.receiptTimeType
})
process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`)
return
}
if (command === "receipt") {
const parsed = await openReceiptPopup({
cdpUrl: args.cdpUrl,
startDate: required(args, "startDate", "start-date YYYY-MM-DD"),
endDate: required(args, "endDate", "end-date YYYY-MM-DD"),
rowIndex: Number(required(args, "rowIndex", "row-index N")),
ecdNo,
pageSize: args.pageSize,
pageNo: args.pageNo,
receiptTimeType: args.receiptTimeType
})
process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`)
return
}
throw new Error(`Unsupported command: ${command}`)
}
main().catch((error) => {
console.error(error.message || error)
process.exitCode = 1
})

View file

@ -0,0 +1,43 @@
const {
BASE_URL,
HIPASS_ENDPOINTS,
LOGIN_URL,
RECEIPT_URL,
USAGE_HISTORY_INIT_URL,
USAGE_HISTORY_LIST_URL,
buildDetailRequest,
buildReceiptRequest,
buildUsageHistoryQuery,
buildUsageHistorySearchPayload,
detectSessionState,
findUsageHistoryEntry,
inspectHipassPage,
parseUsageHistoryList,
} = require("./parse");
const {
buildChromeLaunchCommand,
connectToChrome,
listUsageHistory,
openReceiptPopup,
} = require("./browser");
module.exports = {
BASE_URL,
HIPASS_ENDPOINTS,
LOGIN_URL,
RECEIPT_URL,
USAGE_HISTORY_INIT_URL,
USAGE_HISTORY_LIST_URL,
buildDetailRequest,
buildReceiptRequest,
buildUsageHistoryQuery,
buildChromeLaunchCommand,
buildUsageHistorySearchPayload,
connectToChrome,
detectSessionState,
findUsageHistoryEntry,
inspectHipassPage,
listUsageHistory,
openReceiptPopup,
parseUsageHistoryList,
};

View file

@ -0,0 +1,451 @@
const BASE_URL = "https://www.hipass.co.kr";
const LOGIN_URL = `${BASE_URL}/comm/lginpg.do`;
const USAGE_HISTORY_INIT_URL = `${BASE_URL}/usepculr/InitUsePculrTabSearch.do`;
const USAGE_HISTORY_LIST_URL = `${BASE_URL}/usepculr/UsePculrTabSearchList.do`;
const RECEIPT_URL = `${BASE_URL}/usepculr/UsePculrReceiptPrint.do`;
const DETAIL_URL = `${BASE_URL}/usepculr/UsePculrTabSearchListDetail.do`;
const HIPASS_ENDPOINTS = {
login: "/comm/lginpg.do",
loginPage: "https://www.hipass.co.kr/comm/lginpg.do",
main: "https://www.hipass.co.kr/main.do",
sessionCheck: "/comm/sessionCheck.do",
sessionOut: "/comm//sessionout.do",
sendLoginVerificationCode: "/comm/sendLoginVerificationCode.do",
chkLoginVerificationCode: "/comm/chkLoginVerificationCode.do",
idPwLogin: "/comm/IdPwLogin.do",
idPwLogin90Check: "/comm/IdPwLogin_90Check.do",
usageHistoryInit: "/usepculr/InitUsePculrTabSearch.do",
usageHistoryList: "/usepculr/UsePculrTabSearchList.do",
usageHistoryDetail: "/usepculr/UsePculrTabSearchListDetail.do",
receiptPrint: "/usepculr/UsePculrReceiptPrint.do",
};
const ROW_COLUMN_KEYS = [
"rowNumber",
"workDateTime",
"cardNumberMasked",
"cardAlias",
"vehicleType",
"inTollgateName",
"outTollgateName",
"laneType",
"transactionAmount",
"billDate",
"category",
"baseToll",
"paidToll",
"billedAmount",
];
const TAG_PATTERN = /<[^>]+>/g;
const WHITESPACE_PATTERN = /\s+/g;
function stripTags(value) {
return String(value || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(TAG_PATTERN, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#39;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(WHITESPACE_PATTERN, " ")
.trim();
}
function normalizeCompactDate(value, fieldName) {
const digits = String(value || "").replace(/\D/g, "");
if (digits.length !== 8) {
throw new Error(`${fieldName} must be a YYYYMMDD-compatible date`);
}
return digits;
}
function toStringOrDefault(value, fallback) {
return value == null || value === "" ? fallback : String(value);
}
function buildUsageHistorySearchPayload(options = {}) {
return {
card_kind: toStringOrDefault(options.cardKind, "all"),
card_com: toStringOrDefault(options.cardCompany, "all"),
ecd_no: toStringOrDefault(options.encryptedCardNumber, "all"),
sDate: normalizeCompactDate(options.startDate, "startDate"),
eDate: normalizeCompactDate(options.endDate, "endDate"),
date_type: toStringOrDefault(options.dateType, "work"),
biz_type: toStringOrDefault(options.roadType, "on"),
pageSize: toStringOrDefault(options.pageSize, "10"),
pageNo: toStringOrDefault(options.pageNo, "1"),
order_type: toStringOrDefault(options.orderType, "desc"),
order_item: toStringOrDefault(options.orderItem, "date"),
receipt_time_type: toStringOrDefault(options.receiptTimeType, "display"),
in_ic_nm: toStringOrDefault(options.inTollgateName, ""),
out_ic_nm: toStringOrDefault(options.outTollgateName, ""),
in_ic_code: toStringOrDefault(options.inTollgateCode, ""),
out_ic_code: toStringOrDefault(options.outTollgateCode, ""),
w: toStringOrDefault(options.popupWidth, "742"),
h: toStringOrDefault(options.popupHeight, "436"),
inc_vat: toStringOrDefault(options.includeVat, "nodisplay"),
};
}
function buildUsageHistoryQuery(options = {}) {
const startDate = normalizeCompactDate(options.startDate, "startDate");
const endDate = normalizeCompactDate(options.endDate, "endDate");
const pageSize = Number(toStringOrDefault(options.pageSize, "30"));
if (![10, 30, 50, 80, 100].includes(pageSize)) {
throw new Error("pageSize must be one of 10, 30, 50, 80, 100");
}
if (startDate > endDate) {
throw new Error("startDate must be on or before endDate");
}
return {
card_kind: toStringOrDefault(options.cardKind, "all"),
card_com: toStringOrDefault(options.cardCom ?? options.cardCompany, "all"),
ecd_no: toStringOrDefault(options.ecdNo ?? options.encryptedCardNumber, "all"),
sDate: startDate,
eDate: endDate,
date_type: toStringOrDefault(options.dateType, "work"),
biz_type: toStringOrDefault(options.bizType, "on"),
pageSize: String(pageSize),
pageNo: toStringOrDefault(options.pageNo, "1"),
order_type: toStringOrDefault(options.orderType, "desc"),
order_item: toStringOrDefault(options.orderItem, "date"),
receipt_time_type: toStringOrDefault(options.receiptTimeType, "display"),
in_ic_nm: toStringOrDefault(options.inIcName, ""),
out_ic_nm: toStringOrDefault(options.outIcName, ""),
in_ic_code: toStringOrDefault(options.inIcCode, ""),
out_ic_code: toStringOrDefault(options.outIcCode, ""),
w: toStringOrDefault(options.width, "742"),
h: toStringOrDefault(options.height, "436"),
inc_vat: toStringOrDefault(options.incVat, "nodisplay"),
};
}
function detectSessionState({ url = "", html = "" } = {}) {
const normalizedUrl = String(url || "");
const normalizedHtml = String(html || "");
if (/\/comm\/lginpg\.do(?:\?|$)/.test(normalizedUrl)) {
return {
authenticated: false,
requiresLogin: true,
reason: "login_redirect",
messageType: null,
};
}
const messageTypeMatch = normalizedHtml.match(/var\s+mgs_type\s*=\s*(\d+)/);
const messageType = messageTypeMatch ? Number(messageTypeMatch[1]) : null;
if (messageType === 11) {
return {
authenticated: false,
requiresLogin: true,
reason: "login_required",
messageType,
};
}
if (messageType === 12) {
return {
authenticated: false,
requiresLogin: true,
reason: "session_out",
messageType,
};
}
if (/\/comm\/lginpg\.do/.test(normalizedHtml) && /로그인/.test(normalizedHtml)) {
return {
authenticated: false,
requiresLogin: true,
reason: "login_redirect",
messageType,
};
}
return {
authenticated: true,
requiresLogin: false,
reason: null,
messageType,
};
}
function extractBodyRows(html) {
const tbodyMatch = String(html || "").match(/<tbody[^>]*>([\s\S]*?)<\/tbody>/i);
if (!tbodyMatch) {
return [];
}
return [...tbodyMatch[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((match) => match[0]);
}
function extractCells(rowHtml) {
return [...String(rowHtml || "").matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map((match) => stripTags(match[1]));
}
function parseReceiptAction(rowHtml) {
const candidatePattern = /<(a|button|input)\b([^>]*)>([\s\S]*?)<\/\1>|<input\b([^>]*)\/>/gi;
const candidates = [];
for (const match of rowHtml.matchAll(candidatePattern)) {
const controlType = (match[1] || "input").toLowerCase();
const attrs = (match[2] || match[4] || "").trim();
const inlineText = controlType === "input" ? "" : stripTags(match[3] || "");
const valueMatch = attrs.match(/\bvalue\s*=\s*"([^"]*)"|\bvalue\s*=\s*'([^']*)'/i);
const classMatch = attrs.match(/\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*'([^']*)'/i);
const onclickMatch = attrs.match(/\bonclick\s*=\s*"([^"]*)"|\bonclick\s*=\s*'([^']*)'/i);
const label = stripTags(inlineText || valueMatch?.[1] || valueMatch?.[2] || "");
candidates.push({
controlType,
className: classMatch?.[1] || classMatch?.[2] || "",
onclick: onclickMatch?.[1] || onclickMatch?.[2] || "",
label,
});
}
return (
candidates.find((candidate) => /영수증|출력/.test(candidate.label) || /receipt/i.test(candidate.className)) ||
null
);
}
function parseHiddenFields(html) {
const fields = {};
for (const match of String(html || "").matchAll(/<input\b[^>]*name="([^"]+)"[^>]*value="([^"]*)"[^>]*>/gi)) {
fields[match[1]] = match[2];
}
return fields;
}
function parseFunctionArgs(onclick, functionName) {
const match = String(onclick || "").match(new RegExp(`${functionName}\\(([^)]*)\\)`));
if (!match) {
return [];
}
return [...match[1].matchAll(/'([^']*)'|"([^"]*)"/g)].map((arg) => arg[1] || arg[2] || "");
}
function parseAmount(value) {
const digits = String(value || "").replace(/[^\d]/g, "");
return digits ? Number(digits) : 0;
}
function buildDetailRequest(detailRequest) {
return {
card_kind: detailRequest.card_kind,
work_dates: detailRequest.work_dates,
tolof_cd: detailRequest.tolof_cd,
work_no: detailRequest.work_no,
vhclProsNo: detailRequest.vhclProsNo,
};
}
function buildReceiptRequest(receiptRequest, options = {}) {
return {
...buildDetailRequest(receiptRequest),
receipt_time_type: toStringOrDefault(receiptRequest.receipt_time_type, "display"),
inc_vat: options.includeVat ? "display" : toStringOrDefault(receiptRequest.inc_vat, "nodisplay"),
w: toStringOrDefault(receiptRequest.w, "742"),
h: toStringOrDefault(receiptRequest.h, "436"),
};
}
function parseUsageHistoryList(html) {
const state = detectSessionState({ html });
const hiddenFields = parseHiddenFields(html);
const rows = extractBodyRows(html);
const normalizedRows = rows
.map((rowHtml, index) => {
const cells = extractCells(rowHtml);
if (cells.length < ROW_COLUMN_KEYS.length) {
return null;
}
const detailAction = [...String(rowHtml || "").matchAll(/<a\b[^>]*onclick="([^"]*viewDetail[^"]*)"[^>]*>/gi)][0]?.[1] || "";
const receiptAction = [...String(rowHtml || "").matchAll(/<a\b[^>]*onclick="([^"]*printReceipt[^"]*)"[^>]*>/gi)][0]?.[1] || "";
const detailArgs = parseFunctionArgs(detailAction, "viewDetail");
const receiptArgs = parseFunctionArgs(receiptAction, "printReceipt");
const row = {
rowNumber: Number(cells[0]),
workDateTime: cells[1],
hipassCard: cells[2],
cardAlias: cells[3],
vehicleClass: cells[4],
entryOffice: cells[5],
exitOffice: cells[6],
lane: cells[7],
transactionAmount: parseAmount(cells[8]),
billingDate: cells[9],
chargeType: cells[10],
baseToll: parseAmount(cells[11]),
payableToll: parseAmount(cells[12]),
billAmount: parseAmount(cells[13]),
detailRequest: buildDetailRequest({
card_kind: detailArgs[0],
work_dates: detailArgs[1],
tolof_cd: detailArgs[2],
work_no: detailArgs[3],
vhclProsNo: detailArgs[4],
}),
receiptRequest: buildReceiptRequest({
card_kind: receiptArgs[0],
work_dates: receiptArgs[1],
tolof_cd: receiptArgs[2],
work_no: receiptArgs[3],
vhclProsNo: receiptArgs[4],
receipt_time_type: receiptArgs[5],
inc_vat: receiptArgs[6],
w: hiddenFields.w || "742",
h: hiddenFields.h || "436",
}),
};
return {
row,
item: {
rowIndex: index + 1,
rowNumber: row.rowNumber,
workDateTime: row.workDateTime,
cardNumberMasked: row.hipassCard,
cardAlias: row.cardAlias,
vehicleType: row.vehicleClass,
inTollgateName: row.entryOffice,
outTollgateName: row.exitOffice,
laneType: row.lane,
transactionAmount: `${row.transactionAmount.toLocaleString("en-US")}`,
billDate: row.billingDate,
category: row.chargeType,
baseToll: `${row.baseToll.toLocaleString("en-US")}`,
paidToll: `${row.payableToll.toLocaleString("en-US")}`,
billedAmount: `${row.billAmount.toLocaleString("en-US")}`,
rawHtml: rowHtml,
receiptAction: parseReceiptAction(rowHtml),
detailRequest: row.detailRequest,
receiptRequest: row.receiptRequest,
},
};
})
.filter(Boolean);
return {
state,
query: {
card_kind: hiddenFields.card_kind || "",
card_com: hiddenFields.card_com || "",
ecd_no: hiddenFields.ecd_no || "",
sDate: hiddenFields.sDate || "",
eDate: hiddenFields.eDate || "",
date_type: hiddenFields.date_type || "",
biz_type: hiddenFields.biz_type || "",
pageSize: hiddenFields.pageSize || "",
pageNo: hiddenFields.pageNo || "",
order_type: hiddenFields.order_type || "",
order_item: hiddenFields.order_item || "",
},
rows: normalizedRows.map((entry) => entry.row),
items: normalizedRows.map((entry) => entry.item),
meta: {
listUrl: USAGE_HISTORY_LIST_URL,
receiptUrl: RECEIPT_URL,
detailUrl: DETAIL_URL,
},
};
}
function sameValue(expected, actual) {
return stripTags(expected).toLowerCase() === stripTags(actual).toLowerCase();
}
function findUsageHistoryEntry(items, criteria = {}) {
if (!Array.isArray(items) || items.length === 0) {
throw new Error("No usage-history rows are available");
}
const normalizedCriteria = Object.entries(criteria).filter(([, value]) => value != null && value !== "");
const match = items.find((item) =>
normalizedCriteria.every(([key, value]) => {
if (key === "rowIndex") {
return item.rowIndex === Number(value);
}
return sameValue(value, item[key]);
}),
);
if (!match) {
throw new Error("No matching usage-history row found for the provided criteria");
}
return match;
}
function inspectHipassPage(html) {
const normalizedHtml = String(html || "");
const sessionTimeMatch =
normalizedHtml.match(/id="session_time"[^>]*value="(\d+)"/i) || normalizedHtml.match(/var\s+session_time\s*=\s*(\d+)/);
const sessionTimeSeconds = sessionTimeMatch ? Number(sessionTimeMatch[1]) : null;
if (/sendLoginVerificationCode\.do/.test(normalizedHtml) || /개인\/외국인 로그인/.test(normalizedHtml)) {
return {
pageType: "login",
reloginRequired: true,
sessionTimeSeconds,
endpoints: HIPASS_ENDPOINTS,
reason: "manual_login_required",
};
}
if (/CommonAuthCheck\.jsp/.test(normalizedHtml) || /var\s+mgs_type\s*=/.test(normalizedHtml)) {
return {
pageType: "permission-check",
reloginRequired: true,
sessionTimeSeconds,
endpoints: HIPASS_ENDPOINTS,
reason: "common_auth_check",
};
}
if (/UsePculrTabSearchList\.do/.test(normalizedHtml) || /사용내역 조회/.test(normalizedHtml)) {
return {
pageType: "usage-history-list",
reloginRequired: false,
sessionTimeSeconds,
endpoints: HIPASS_ENDPOINTS,
reason: null,
};
}
return {
pageType: "unknown",
reloginRequired: false,
sessionTimeSeconds,
endpoints: HIPASS_ENDPOINTS,
reason: null,
};
}
module.exports = {
BASE_URL,
HIPASS_ENDPOINTS,
LOGIN_URL,
RECEIPT_URL,
USAGE_HISTORY_INIT_URL,
USAGE_HISTORY_LIST_URL,
buildDetailRequest,
buildReceiptRequest,
buildUsageHistoryQuery,
buildUsageHistorySearchPayload,
detectSessionState,
findUsageHistoryEntry,
inspectHipassPage,
parseUsageHistoryList,
stripTags,
};

View file

@ -0,0 +1,58 @@
function decodeHtml(value) {
return String(value || "")
.replace(/&nbsp;|&#160;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#39;/gi, "'")
.replace(/&quot;/gi, '"')
}
function stripTags(value) {
return decodeHtml(String(value || "").replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim()
}
function cleanText(value) {
return stripTags(value).replace(/\s+/g, " ").trim()
}
function toWonNumber(value) {
const digits = String(value || "").replace(/[^\d-]/g, "")
if (!digits) {
return 0
}
const amount = Number(digits)
return Number.isFinite(amount) ? amount : 0
}
function normalizeDate(value, label) {
const digits = String(value || "").replace(/\D/g, "")
if (digits.length !== 8) {
throw new Error(`${label} must be an 8-digit YYYYMMDD date. Received: ${value}`)
}
return digits
}
function readAttribute(tag, name) {
const pattern = new RegExp(`${name}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i")
const match = String(tag || "").match(pattern)
return match ? decodeHtml(match[2]).trim() : ""
}
function extractTitle(html) {
const match = String(html || "").match(/<title>([\s\S]*?)<\/title>/i)
return match ? cleanText(match[1]) : ""
}
module.exports = {
cleanText,
decodeHtml,
extractTitle,
normalizeDate,
readAttribute,
stripTags,
toWonNumber
}

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<title>개인/외국인 로그인 | 고속도로 통행료 홈페이지</title>
</head>
<body>
<form id="loginForm">
<input type="hidden" id="session_time" value="1200" />
</form>
<script>
var session_time = 1200;
function refreshSession() {
return "/comm/sessionCheck.do";
}
var loginEndpoints = [
"/comm/sendLoginVerificationCode.do",
"/comm/chkLoginVerificationCode.do",
"/comm/IdPwLogin.do",
"/comm/IdPwLogin_90Check.do"
];
</script>
</body>
</html>

View file

@ -0,0 +1,14 @@
<!-- CommonAuthCheck.jsp -->
<!DOCTYPE html>
<html lang="ko">
<head>
<title>권한 확인 | 하이패스 홈페이지</title>
</head>
<body>
<script>
var mgs_type = 12;
alert("로그인 상태가 아니거나 세션이 종료되었습니다.");
location.href = "/comm/lginpg.do";
</script>
</body>
</html>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<script>
var mgs_type = 12;
var gourl = "/usepculr/InitUsePculrTabSearch.do";
if (mgs_type == 12) {
alert("로그인 하지 않았거나 장시간 사용을 하지 않았거나, 기타 이유로 인하여 세션이 종료되어 로그인 화면으로 되돌아 갑니다.");
self.top.document.location.href = "/comm/lginpg.do";
}
</script>
</head>
<body></body>
</html>

View file

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<title>사용내역 조회 | 하이패스 홈페이지</title>
</head>
<body>
<form id="hpForm" method="post" action="/usepculr/UsePculrTabSearchList.do" target="if_main_post">
<input type="hidden" name="card_kind" value="2" />
<input type="hidden" name="card_com" value="005" />
<input type="hidden" name="ecd_no" value="QmFzZTY0RW5jcnlwdGVkQ2FyZE5vPT0=" />
<input type="hidden" name="sDate" value="20260401" />
<input type="hidden" name="eDate" value="20260407" />
<input type="hidden" name="date_type" value="work" />
<input type="hidden" name="biz_type" value="on" />
<input type="hidden" name="pageSize" value="30" />
<input type="hidden" name="pageNo" value="1" />
<input type="hidden" name="order_type" value="desc" />
<input type="hidden" name="order_item" value="date" />
</form>
<table class="list_table">
<thead>
<tr>
<th>No</th>
<th>거래일시</th>
<th>하이패스 카드</th>
<th>카드별칭</th>
<th>차종</th>
<th>입구영업소</th>
<th>출구영업소</th>
<th>이용차로</th>
<th>거래금액</th>
<th>청구일자</th>
<th>구분</th>
<th>기준통행료</th>
<th>납부할통행료</th>
<th>청구금액</th>
<th>기능</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2026-04-07 08:30</td>
<td>0020-01**-****-2086</td>
<td>가족카드</td>
<td>1종</td>
<td>서울TG</td>
<td>판교IC</td>
<td>하이패스</td>
<td>1,200원</td>
<td>2026-04-10</td>
<td>출구</td>
<td>1,200원</td>
<td>1,200원</td>
<td>1,200원</td>
<td>
<a href="#" onclick="viewDetail('2','20260407083012','A12','000123','VH001'); return false;">상세</a>
<a href="#" onclick="printReceipt('2','20260407083012','A12','000123','VH001','display','nodisplay'); return false;">영수증</a>
</td>
</tr>
<tr>
<td>2</td>
<td>2026-04-06 20:15</td>
<td>0020-01**-****-2086</td>
<td>출퇴근</td>
<td>1종</td>
<td>판교IC</td>
<td>서울TG</td>
<td>일반</td>
<td>1,200원</td>
<td>2026-04-10</td>
<td>입구</td>
<td>1,200원</td>
<td>1,200원</td>
<td>1,200원</td>
<td>
<a href="#" onclick="viewDetail('2','20260406201540','A12','000124','VH002'); return false;">상세</a>
<a href="#" onclick="printReceipt('2','20260406201540','A12','000124','VH002','display','nodisplay'); return false;">영수증</a>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View file

@ -0,0 +1,459 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const {
HIPASS_ENDPOINTS,
buildDetailRequest,
buildReceiptRequest,
buildUsageHistoryQuery,
inspectHipassPage,
parseUsageHistoryList,
RECEIPT_URL,
USAGE_HISTORY_INIT_URL,
USAGE_HISTORY_LIST_URL
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const usageHistoryHtml = fs.readFileSync(path.join(fixturesDir, "usage-history-list.html"), "utf8");
const loginHtml = fs.readFileSync(path.join(fixturesDir, "login-page.html"), "utf8");
const permissionHtml = fs.readFileSync(path.join(fixturesDir, "permission-check.html"), "utf8");
async function withMockedBrowserModule(factory, callback) {
const browserModulePath = require.resolve("../src/browser");
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "playwright-core" || request === "playwright") {
return factory();
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[browserModulePath];
try {
const browserModule = require("../src/browser");
return await callback(browserModule);
} finally {
Module._load = originalLoad;
delete require.cache[browserModulePath];
}
}
test("buildUsageHistoryQuery normalizes defaults for logged-in usage-history searches", () => {
const query = buildUsageHistoryQuery({
startDate: "2026-04-01",
endDate: "2026-04-07",
ecdNo: "QmFzZTY0RW5jcnlwdGVkQ2FyZE5vPT0=",
cardCom: "005"
});
assert.deepEqual(query, {
card_kind: "all",
card_com: "005",
ecd_no: "QmFzZTY0RW5jcnlwdGVkQ2FyZE5vPT0=",
sDate: "20260401",
eDate: "20260407",
date_type: "work",
biz_type: "on",
pageSize: "30",
pageNo: "1",
order_type: "desc",
order_item: "date",
receipt_time_type: "display",
in_ic_nm: "",
out_ic_nm: "",
in_ic_code: "",
out_ic_code: "",
w: "742",
h: "436",
inc_vat: "nodisplay"
});
});
test("buildUsageHistoryQuery accepts the encryptedCardNumber alias and the CLI help documents it", () => {
const query = buildUsageHistoryQuery({
startDate: "2026-04-01",
endDate: "2026-04-07",
encryptedCardNumber: "alias-card-token"
});
assert.equal(query.ecd_no, "alias-card-token");
const help = childProcess.execFileSync(process.execPath, [path.join(__dirname, "..", "src", "cli.js"), "--help"], {
encoding: "utf8"
});
assert.match(help, /--encrypted-card-number VALUE/);
});
test("buildUsageHistoryQuery rejects invalid date windows and unsupported paging options", () => {
assert.throws(
() =>
buildUsageHistoryQuery({
startDate: "20260408",
endDate: "20260407"
}),
/startDate must be on or before endDate/,
);
assert.throws(
() =>
buildUsageHistoryQuery({
startDate: "20260401",
endDate: "20260407",
pageSize: 15
}),
/pageSize must be one of 10, 30, 50, 80, 100/,
);
});
test("parseUsageHistoryList extracts transaction rows and the receipt/detail payloads", () => {
const list = parseUsageHistoryList(usageHistoryHtml);
assert.equal(list.query.sDate, "20260401");
assert.equal(list.query.eDate, "20260407");
assert.equal(list.rows.length, 2);
assert.deepEqual(list.rows[0], {
rowNumber: 1,
workDateTime: "2026-04-07 08:30",
hipassCard: "0020-01**-****-2086",
cardAlias: "가족카드",
vehicleClass: "1종",
entryOffice: "서울TG",
exitOffice: "판교IC",
lane: "하이패스",
transactionAmount: 1200,
billingDate: "2026-04-10",
chargeType: "출구",
baseToll: 1200,
payableToll: 1200,
billAmount: 1200,
detailRequest: {
card_kind: "2",
work_dates: "20260407083012",
tolof_cd: "A12",
work_no: "000123",
vhclProsNo: "VH001"
},
receiptRequest: {
card_kind: "2",
work_dates: "20260407083012",
tolof_cd: "A12",
work_no: "000123",
vhclProsNo: "VH001",
receipt_time_type: "display",
inc_vat: "nodisplay",
w: "742",
h: "436"
}
});
});
test("buildDetailRequest and buildReceiptRequest preserve the expected submit field names", () => {
const row = parseUsageHistoryList(usageHistoryHtml).rows[1];
assert.deepEqual(buildDetailRequest(row.detailRequest), {
card_kind: "2",
work_dates: "20260406201540",
tolof_cd: "A12",
work_no: "000124",
vhclProsNo: "VH002"
});
assert.deepEqual(buildReceiptRequest(row.receiptRequest, { includeVat: true }), {
card_kind: "2",
work_dates: "20260406201540",
tolof_cd: "A12",
work_no: "000124",
vhclProsNo: "VH002",
receipt_time_type: "display",
inc_vat: "display",
w: "742",
h: "436"
});
});
test("inspectHipassPage flags login and permission-check pages as re-login-required", () => {
const loginPage = inspectHipassPage(loginHtml);
const permissionPage = inspectHipassPage(permissionHtml);
const listPage = inspectHipassPage(usageHistoryHtml);
assert.equal(loginPage.pageType, "login");
assert.equal(loginPage.reloginRequired, true);
assert.equal(loginPage.sessionTimeSeconds, 1200);
assert.equal(loginPage.endpoints.sessionCheck, HIPASS_ENDPOINTS.sessionCheck);
assert.equal(loginPage.endpoints.idPwLogin90Check, HIPASS_ENDPOINTS.idPwLogin90Check);
assert.equal(permissionPage.pageType, "permission-check");
assert.equal(permissionPage.reloginRequired, true);
assert.equal(permissionPage.reason, "common_auth_check");
assert.equal(listPage.pageType, "usage-history-list");
assert.equal(listPage.reloginRequired, false);
});
test("list command accepts --encrypted-card-number and reaches the usage-history init endpoint with a mocked CDP browser", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hipass-receipt-playwright-"));
const mockHookPath = path.join(tempDir, "mock-playwright.js");
const fakeModuleSource = `
const Module = require("node:module");
const fixtureHtml = ${JSON.stringify(usageHistoryHtml)};
let submittedQuery = null;
const frame = {
name() {
return "if_main_post";
},
url() {
return "https://www.hipass.co.kr/usepculr/UsePculrTabSearchList.do";
},
async waitForLoadState() {},
async content() {
return fixtureHtml.replace(
'value="QmFzZTY0RW5jcnlwdGVkQ2FyZE5vPT0="',
'value="' + (submittedQuery?.ecd_no || "") + '"',
);
}
};
const page = {
async goto(url) {
if (!url) {
throw new Error("goto received undefined");
}
},
async content() {
return "<html><body>사용내역 조회</body></html>";
},
async evaluate(_callback, query) {
submittedQuery = query;
},
frames() {
return [frame];
},
async waitForTimeout() {}
};
const fakePlaywright = {
chromium: {
async connectOverCDP() {
return {
contexts() {
return [{
pages() {
return [page];
}
}];
}
};
}
}
};
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "playwright-core" || request === "playwright") {
return fakePlaywright;
}
return originalLoad.call(this, request, parent, isMain);
};
`;
fs.writeFileSync(mockHookPath, fakeModuleSource);
const output = childProcess.execFileSync(
process.execPath,
[
path.join(__dirname, "..", "src", "cli.js"),
"list",
"--start-date",
"2026-04-01",
"--end-date",
"2026-04-07",
"--encrypted-card-number",
"ENC-ONLY-ALIAS"
],
{
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: {
...process.env,
NODE_OPTIONS: `${process.env.NODE_OPTIONS ? `${process.env.NODE_OPTIONS} ` : ""}--require ${mockHookPath}`
}
},
);
const parsed = JSON.parse(output);
assert.equal(parsed.rows.length, 2);
assert.equal(parsed.query.sDate, "20260401");
assert.equal(parsed.query.ecd_no, "ENC-ONLY-ALIAS");
});
test("listUsageHistory uses the absolute usage-history URL and closes the browser connection", async () => {
const state = {
closed: false,
gotoUrl: null
};
await withMockedBrowserModule(
() => {
const frame = {
name() {
return "if_main_post";
},
url() {
return USAGE_HISTORY_LIST_URL;
},
async waitForLoadState() {},
async content() {
return usageHistoryHtml;
}
};
const page = {
async goto(url) {
state.gotoUrl = url;
},
async content() {
return "<html><body>사용내역 조회</body></html>";
},
async evaluate() {},
frames() {
return [frame];
},
async waitForTimeout() {}
};
const context = {
pages() {
return [page];
}
};
return {
chromium: {
async connectOverCDP() {
return {
contexts() {
return [context];
},
async close() {
state.closed = true;
}
};
}
}
};
},
async ({ listUsageHistory }) => {
const parsed = await listUsageHistory({
startDate: "2026-04-01",
endDate: "2026-04-07"
});
assert.equal(parsed.rows.length, 2);
assert.equal(state.gotoUrl, USAGE_HISTORY_INIT_URL);
assert.equal(state.closed, true);
},
);
});
test("openReceiptPopup uses the receipt flow and closes the browser connection", async () => {
const state = {
closed: false,
gotoUrl: null
};
await withMockedBrowserModule(
() => {
const popup = {
url() {
return RECEIPT_URL;
},
async title() {
return "영수증 출력";
},
async waitForLoadState() {}
};
const frame = {
name() {
return "if_main_post";
},
url() {
return USAGE_HISTORY_LIST_URL;
},
async waitForLoadState() {},
async content() {
return usageHistoryHtml;
},
locator() {
return {
nth() {
return {
async evaluate() {}
};
}
};
}
};
const page = {
async goto(url) {
state.gotoUrl = url;
},
async content() {
return "<html><body>사용내역 조회</body></html>";
},
async evaluate() {},
frames() {
return [frame];
},
async waitForTimeout() {}
};
const context = {
pages() {
return [page];
},
async waitForEvent() {
return popup;
}
};
return {
chromium: {
async connectOverCDP() {
return {
contexts() {
return [context];
},
async close() {
state.closed = true;
}
};
}
}
};
},
async ({ openReceiptPopup }) => {
const parsed = await openReceiptPopup({
startDate: "2026-04-01",
endDate: "2026-04-07",
rowIndex: 1
});
assert.equal(parsed.popupCaptured, true);
assert.equal(parsed.popupUrl, RECEIPT_URL);
assert.equal(state.gotoUrl, USAGE_HISTORY_INIT_URL);
assert.equal(state.closed, true);
},
);
});

View file

@ -1,22 +1,28 @@
# k-skill-proxy
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회, 기상청 단기예보, 서울 지하철 실시간 도착정보, 한강홍수통제소 수위 정보를 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
## 현재 제공 엔드포인트
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
## 환경변수
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
- `KMA_OPEN_API_KEY` — 프록시 서버 쪽 기상청 단기예보 upstream key
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
- `HRFCO_OPEN_API_KEY` — 프록시 서버 쪽 한강홍수통제소 upstream key
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
- `KSKILL_PROXY_PORT` — 기본 `4020`
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
@ -40,6 +46,14 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
한국 날씨 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korea-weather/forecast' \
--data-urlencode 'lat=37.5665' \
--data-urlencode 'lon=126.9780'
```
한강 수위 정보 예시:
```bash
@ -47,7 +61,15 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다.
한국 주식 검색 예시:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/korean-stock/search' \
--data-urlencode 'q=삼성전자' \
--data-urlencode 'bas_dd=20260404'
```
프록시는 내부적으로 `waterlevel/info.json` 으로 관측소를 해석하고, `waterlevel/list/10M/{WLOBSCD}.json` 으로 최신 수위/유량을 조회합니다. 한국 주식 route는 KRX Open API에 `AUTH_KEY` 헤더를 서버 쪽에서만 주입합니다.
## PM2 실행

View file

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

View file

@ -0,0 +1,300 @@
const KRX_MARKETS = ["KOSPI", "KOSDAQ", "KONEX"];
const KRX_BASE_INFO_URLS = {
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_isu_base_info",
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_isu_base_info",
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_isu_base_info"
};
const KRX_TRADE_INFO_URLS = {
KOSPI: "https://data-dbg.krx.co.kr/svc/apis/sto/stk_bydd_trd",
KOSDAQ: "https://data-dbg.krx.co.kr/svc/apis/sto/ksq_bydd_trd",
KONEX: "https://data-dbg.krx.co.kr/svc/apis/sto/knx_bydd_trd"
};
function buildUrl(baseUrl, params) {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") {
continue;
}
url.searchParams.set(key, String(value));
}
return url;
}
function parseNumber(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const normalized = String(value).replace(/,/g, "").trim();
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function formatYmd(value) {
const raw = String(value || "").trim();
if (!/^\d{8}$/.test(raw)) {
return raw || null;
}
return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
}
function getCurrentKstDate() {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit"
});
return formatter.format(new Date()).replace(/-/g, "");
}
function normalizeBaseItem(item, market) {
const normalized = {
market,
code: item.ISU_SRT_CD || item.ISU_CD || null,
standard_code: item.ISU_CD || null,
short_code: item.ISU_SRT_CD || item.ISU_CD || null,
name: item.ISU_NM || null,
name_ko: item.ISU_NM || null,
short_name: item.ISU_ABBRV || null,
name_abbr: item.ISU_ABBRV || null,
english_name: item.ISU_ENG_NM || null,
name_en: item.ISU_ENG_NM || null,
listed_at: formatYmd(item.LIST_DD),
market_name: item.MKT_TP_NM || market,
security_group: item.SECUGRP_NM || null,
section_type: item.SECT_TP_NM || null,
sector_type: item.SECT_TP_NM || null,
stock_certificate_type: item.KIND_STKCERT_TP_NM || null,
stock_kind: item.KIND_STKCERT_TP_NM || null,
par_value: parseNumber(item.PARVAL),
listed_shares: parseNumber(item.LIST_SHRS)
};
return normalized;
}
function normalizeTradeItem(item, market, baseItem = null) {
const normalized = {
market,
code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
short_code: baseItem?.code || item.ISU_SRT_CD || item.ISU_CD || null,
standard_code: item.ISU_CD || baseItem?.standard_code || null,
base_date: item.BAS_DD || null,
date: formatYmd(item.BAS_DD),
name: item.ISU_NM || baseItem?.name || null,
name_ko: item.ISU_NM || baseItem?.name || null,
market_name: item.MKT_NM || baseItem?.market_name || market,
section_type: item.SECT_TP_NM || baseItem?.section_type || null,
sector_type: item.SECT_TP_NM || baseItem?.section_type || null,
close_price: parseNumber(item.TDD_CLSPRC),
change_price: parseNumber(item.CMPPREVDD_PRC),
fluctuation_rate: parseNumber(item.FLUC_RT),
change_rate: parseNumber(item.FLUC_RT),
open_price: parseNumber(item.TDD_OPNPRC),
high_price: parseNumber(item.TDD_HGPRC),
low_price: parseNumber(item.TDD_LWPRC),
trading_volume: parseNumber(item.ACC_TRDVOL),
volume: parseNumber(item.ACC_TRDVOL),
trading_value: parseNumber(item.ACC_TRDVAL),
traded_value: parseNumber(item.ACC_TRDVAL),
market_cap: parseNumber(item.MKTCAP),
listed_shares: parseNumber(item.LIST_SHRS || baseItem?.listed_shares)
};
return normalized;
}
function matchesCodes(item, codes) {
if (!codes?.length) {
return true;
}
return codes.some((code) => code === item.ISU_CD || code === item.ISU_SRT_CD);
}
async function krxRequest(url, apiKey, fetchImpl = global.fetch) {
if (!apiKey) {
const error = new Error("KRX_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const response = await fetchImpl(url, {
headers: {
"content-type": "application/json",
AUTH_KEY: apiKey
},
signal: AbortSignal.timeout(20000)
});
if (!response.ok) {
const error = new Error(`KRX API HTTP 오류 (status: ${response.status}): ${response.statusText}`);
error.code = "upstream_error";
error.statusCode = 502;
throw error;
}
const payload = await response.json();
if (!Array.isArray(payload.OutBlock_1)) {
const error = new Error("KRX API 오류: 응답에 OutBlock_1 배열이 없습니다.");
error.code = "krx_api_error";
error.statusCode = 502;
throw error;
}
return payload.OutBlock_1;
}
async function fetchBaseInfo({ market, basDd = getCurrentKstDate(), codeList = [], apiKey, fetchImpl = global.fetch }) {
const items = await krxRequest(buildUrl(KRX_BASE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
return items
.filter((item) => matchesCodes(item, codeList))
.map((item) => normalizeBaseItem(item, market));
}
async function fetchTradeInfo({ market, basDd = getCurrentKstDate(), codeList, apiKey, fetchImpl = global.fetch }) {
const tradeItems = await krxRequest(buildUrl(KRX_TRADE_INFO_URLS[market], { basDd }), apiKey, fetchImpl);
const directlyMatched = tradeItems.filter((item) => matchesCodes(item, codeList));
if (directlyMatched.length > 0) {
return directlyMatched.map((item) => normalizeTradeItem(item, market));
}
const baseItems = await fetchBaseInfo({ market, basDd, codeList, apiKey, fetchImpl });
if (baseItems.length === 0) {
return [];
}
const standardCodes = new Set(baseItems.map((item) => item.standard_code).filter(Boolean));
const baseByStandardCode = new Map(baseItems.map((item) => [item.standard_code, item]));
return tradeItems
.filter((item) => standardCodes.has(item.ISU_CD) || baseByStandardCode.has(item.ISU_CD))
.map((item) => normalizeTradeItem(item, market, baseByStandardCode.get(item.ISU_CD)));
}
function tokenize(query) {
return String(query)
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
}
function scoreSearchMatch(item, query) {
const raw = String(query).trim().toLowerCase();
if (!raw) {
return -1;
}
const haystacks = [
item.code,
item.standard_code,
item.name,
item.short_name,
item.english_name
].map((value) => String(value || "").toLowerCase());
if (haystacks.some((value) => value === raw)) {
return 100;
}
const tokens = tokenize(raw);
if (tokens.length > 0 && tokens.every((token) => haystacks.some((value) => value.includes(token)))) {
return 50;
}
return -1;
}
function buildBaseInfoSnapshotCacheKey({ market, basDd }) {
return `krx-base-info:${market}:${basDd}`;
}
async function fetchBaseInfoSnapshot({
market,
basDd,
apiKey,
fetchImpl = global.fetch,
cache = null,
cacheTtlMs = 0
}) {
const cacheKey = cache ? buildBaseInfoSnapshotCacheKey({ market, basDd }) : null;
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
}
const items = await fetchBaseInfo({ market, basDd, apiKey, fetchImpl });
if (cacheKey) {
cache.set(cacheKey, items, cacheTtlMs);
}
return items;
}
async function searchStocks({
query,
basDd = getCurrentKstDate(),
market = null,
limit = 10,
apiKey,
fetchImpl = global.fetch,
cache = null,
cacheTtlMs = 0
}) {
const markets = market ? [market] : KRX_MARKETS;
const settledResults = await Promise.allSettled(markets.map(async (entryMarket) => ({
market: entryMarket,
items: await fetchBaseInfoSnapshot({
market: entryMarket,
basDd,
apiKey,
fetchImpl,
cache,
cacheTtlMs
})
})));
const successfulResults = settledResults
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
if (successfulResults.length === 0) {
const firstFailure = settledResults.find((result) => result.status === "rejected");
throw firstFailure?.reason || new Error("KRX search failed for every market.");
}
return {
items: successfulResults
.flatMap(({ market: entryMarket, items }) =>
items
.map((item) => ({ ...item, market: item.market || entryMarket, score: scoreSearchMatch(item, query) }))
.filter((item) => item.score >= 0)
)
.sort((left, right) => right.score - left.score || left.name.localeCompare(right.name, "ko"))
.slice(0, limit)
.map(({ score, ...item }) => item)
};
}
module.exports = {
KRX_MARKETS,
fetchBaseInfo,
fetchTradeInfo,
getCurrentKstDate,
searchStocks
};

View file

@ -3,11 +3,16 @@ const Fastify = require("fastify");
const { fetchFineDustReport } = require("./airkorea");
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
const { fetchWaterLevelReport } = require("./hrfco");
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const KMA_FORECAST_BASE_TIMES = ["0200", "0500", "0800", "1100", "1400", "1700", "2000", "2300"];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const KMA_FORECAST_READY_MINUTE = 10;
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
const NEIS_MEAL_SERVICE_URL = "https://open.neis.go.kr/hub/mealServiceDietInfo";
const NEIS_SCHOOL_INFO_URL = "https://open.neis.go.kr/hub/schoolInfo";
@ -43,18 +48,97 @@ function trimOrNull(value) {
return trimmed;
}
function padNumber(value, length) {
return String(value).padStart(length, "0");
}
function formatKstDate(date) {
const kstDate = new Date(date.getTime() + KST_OFFSET_MS);
return `${padNumber(kstDate.getUTCFullYear(), 4)}${padNumber(kstDate.getUTCMonth() + 1, 2)}${padNumber(kstDate.getUTCDate(), 2)}`;
}
function resolveLatestKmaForecastBase(now = new Date()) {
const kstDate = new Date(now.getTime() + KST_OFFSET_MS);
const currentMinutes = (kstDate.getUTCHours() * 60) + kstDate.getUTCMinutes();
for (let index = KMA_FORECAST_BASE_TIMES.length - 1; index >= 0; index -= 1) {
const baseTime = KMA_FORECAST_BASE_TIMES[index];
const baseHour = Number.parseInt(baseTime.slice(0, 2), 10);
const baseMinute = Number.parseInt(baseTime.slice(2, 4), 10);
const readyMinutes = (baseHour * 60) + baseMinute + KMA_FORECAST_READY_MINUTE;
if (currentMinutes >= readyMinutes) {
return {
baseDate: formatKstDate(now),
baseTime
};
}
}
return {
baseDate: formatKstDate(new Date(now.getTime() - (24 * 60 * 60 * 1000))),
baseTime: KMA_FORECAST_BASE_TIMES[KMA_FORECAST_BASE_TIMES.length - 1]
};
}
function convertLatLonToKmaGrid(latitude, longitude) {
const RE = 6371.00877;
const GRID = 5.0;
const SLAT1 = 30.0;
const SLAT2 = 60.0;
const OLON = 126.0;
const OLAT = 38.0;
const XO = 43;
const YO = 136;
const DEGRAD = Math.PI / 180.0;
const re = RE / GRID;
const slat1 = SLAT1 * DEGRAD;
const slat2 = SLAT2 * DEGRAD;
const olon = OLON * DEGRAD;
const olat = OLAT * DEGRAD;
let sn = Math.tan((Math.PI * 0.25) + (slat2 * 0.5)) / Math.tan((Math.PI * 0.25) + (slat1 * 0.5));
sn = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
let sf = Math.tan((Math.PI * 0.25) + (slat1 * 0.5));
sf = (Math.pow(sf, sn) * Math.cos(slat1)) / sn;
let ro = Math.tan((Math.PI * 0.25) + (olat * 0.5));
ro = (re * sf) / Math.pow(ro, sn);
let ra = Math.tan((Math.PI * 0.25) + ((latitude * DEGRAD) * 0.5));
ra = (re * sf) / Math.pow(ra, sn);
let theta = (longitude * DEGRAD) - olon;
if (theta > Math.PI) {
theta -= 2.0 * Math.PI;
}
if (theta < -Math.PI) {
theta += 2.0 * Math.PI;
}
theta *= sn;
return {
nx: Math.floor((ra * Math.sin(theta)) + XO + 0.5),
ny: Math.floor(ro - (ra * Math.cos(theta)) + YO + 0.5)
};
}
function buildConfig(env = process.env) {
return {
host: env.KSKILL_PROXY_HOST || "127.0.0.1",
port: parseInteger(env.KSKILL_PROXY_PORT, 4020),
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
kmaOpenApiKey: trimOrNull(env.KMA_OPEN_API_KEY),
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
keduInfoKey: trimOrNull(env.KEDU_INFO_KEY),
krxApiKey: trimOrNull(env.KRX_API_KEY),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -95,7 +179,7 @@ function buildRateLimiter(config) {
const state = new Map();
return function rateLimit(request, reply) {
const key = trimOrNull(request.headers["cf-connecting-ip"]) || request.ip || "unknown";
const key = request.ip || "unknown";
const now = Date.now();
const current = state.get(key);
@ -155,6 +239,76 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeKmaForecastQuery(query, now = new Date()) {
const rawNx = parseInteger(query.nx, Number.NaN);
const rawNy = parseInteger(query.ny, Number.NaN);
const latitude = parseFloatValue(query.lat ?? query.latitude);
const longitude = parseFloatValue(query.lon ?? query.longitude ?? query.lng);
const hasGrid = Number.isFinite(rawNx) && Number.isFinite(rawNy);
const hasLatLon = Number.isFinite(latitude) && Number.isFinite(longitude);
if (!hasGrid && !hasLatLon) {
throw new Error("Provide nx/ny or lat/lon.");
}
if ((Number.isFinite(rawNx) && !Number.isFinite(rawNy)) || (!Number.isFinite(rawNx) && Number.isFinite(rawNy))) {
throw new Error("Provide both nx and ny.");
}
if ((Number.isFinite(latitude) && !Number.isFinite(longitude)) || (!Number.isFinite(latitude) && Number.isFinite(longitude))) {
throw new Error("Provide both lat and lon.");
}
if (hasLatLon && (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180)) {
throw new Error("Provide valid lat and lon.");
}
const pageNo = parseInteger(query.pageNo ?? query.page_no, 1);
const numOfRows = parseInteger(query.numOfRows ?? query.num_of_rows, 1000);
const dataType = trimOrNull(query.dataType ?? query.data_type)?.toUpperCase() || "JSON";
const rawBaseDate = trimOrNull(query.baseDate ?? query.base_date);
const rawBaseTime = trimOrNull(query.baseTime ?? query.base_time);
if ((rawBaseDate && !rawBaseTime) || (!rawBaseDate && rawBaseTime)) {
throw new Error("Provide both baseDate and baseTime.");
}
if (pageNo < 1 || numOfRows < 1) {
throw new Error("Provide valid pageNo and numOfRows.");
}
if (!["JSON", "XML"].includes(dataType)) {
throw new Error("Provide dataType as JSON or XML.");
}
const { baseDate, baseTime } = rawBaseDate && rawBaseTime
? {
baseDate: rawBaseDate,
baseTime: rawBaseTime
}
: resolveLatestKmaForecastBase(now);
if (!/^\d{8}$/.test(baseDate) || !/^\d{4}$/.test(baseTime)) {
throw new Error("Provide baseDate as YYYYMMDD and baseTime as HHMM.");
}
const grid = hasGrid ? { nx: rawNx, ny: rawNy } : convertLatLonToKmaGrid(latitude, longitude);
if (!Number.isFinite(grid.nx) || !Number.isFinite(grid.ny)) {
throw new Error(hasGrid ? "Provide valid nx and ny." : "Provide valid lat and lon.");
}
return {
baseDate,
baseTime,
nx: grid.nx,
ny: grid.ny,
pageNo,
numOfRows,
dataType
};
}
function normalizeOpinetAroundQuery(query) {
const x = parseFloatValue(query.x);
const y = parseFloatValue(query.y);
@ -345,6 +499,68 @@ function normalizeHanRiverWaterLevelQuery(query) {
};
}
function normalizeKrxMarket(value) {
const normalized = trimOrNull(value)?.toUpperCase();
if (!normalized || !KRX_MARKETS.includes(normalized)) {
throw new Error(`Provide market as one of: ${KRX_MARKETS.join(", ")}.`);
}
return normalized;
}
function normalizeKoreanStockDate(value) {
const normalized = trimOrNull(value) || getCurrentKstDate();
if (!/^\d{8}$/.test(normalized)) {
throw new Error("Provide bas_dd/date as YYYYMMDD.");
}
return normalized;
}
function normalizeKoreanStockCodes(value) {
const values = Array.isArray(value) ? value : [value];
const codes = values
.flatMap((entry) => String(entry || "").split(","))
.map((entry) => entry.trim())
.filter(Boolean);
if (codes.length === 0) {
throw new Error("Provide code/stockCode/codes.");
}
return [...new Set(codes)];
}
function normalizeKoreanStockSearchQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide q/query.");
}
const basDd = normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date);
const marketValue = trimOrNull(query.market);
const limit = parseInteger(query.limit, 10);
if (limit < 1 || limit > 20) {
throw new Error("limit must be between 1 and 20.");
}
return {
q,
basDd,
market: marketValue ? normalizeKrxMarket(marketValue) : null,
limit
};
}
function normalizeKoreanStockLookupQuery(query) {
return {
market: normalizeKrxMarket(query.market),
code: normalizeKoreanStockCodes(query.code ?? query.codes ?? query.codeList ?? query.stockCode ?? query.stock_code)[0],
basDd: normalizeKoreanStockDate(query.bas_dd ?? query.basDd ?? query.date)
};
}
function isAllowedAirKoreaRoute(service, operation) {
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
@ -432,6 +648,49 @@ async function proxySeoulSubwayRequest({
};
}
async function proxyKmaWeatherRequest({
baseDate,
baseTime,
nx,
ny,
pageNo = 1,
numOfRows = 1000,
dataType = "JSON",
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "KMA_OPEN_API_KEY is not configured on the proxy server."
})
};
}
const url = new URL(`${DATA_GO_KR_UPSTREAM_BASE_URL}/1360000/VilageFcstInfoService_2.0/getVilageFcst`);
url.searchParams.set("serviceKey", apiKey);
url.searchParams.set("pageNo", String(pageNo));
url.searchParams.set("numOfRows", String(numOfRows));
url.searchParams.set("dataType", dataType);
url.searchParams.set("base_date", baseDate);
url.searchParams.set("base_time", baseTime);
url.searchParams.set("nx", String(nx));
url.searchParams.set("ny", String(ny));
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
async function proxyOpinetRequest({ path, params, apiKey, fetchImpl = global.fetch }) {
if (!apiKey) {
return {
@ -592,8 +851,7 @@ async function proxyNeisSchoolInfoRequest({
};
}
function buildServer({ env = process.env, provider = null } = {}) {
function buildServer({ env = process.env, provider = null, now = () => new Date() } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
const rateLimit = buildRateLimiter(config);
@ -624,12 +882,14 @@ function buildServer({ env = process.env, provider = null } = {}) {
port: config.port,
upstreams: {
airKoreaConfigured: Boolean(config.airKoreaApiKey),
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
hrfcoConfigured: Boolean(config.hrfcoApiKey),
opinetConfigured: Boolean(config.opinetApiKey),
molitConfigured: Boolean(config.molitApiKey),
neisSchoolMealConfigured: Boolean(config.keduInfoKey)
krxConfigured: Boolean(config.krxApiKey)
},
auth: {
tokenRequired: false
@ -771,6 +1031,67 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/korea-weather/forecast", async (request, reply) => {
let normalized;
try {
normalized = normalizeKmaForecastQuery(request.query || {}, now());
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korea-weather-forecast",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxyKmaWeatherRequest({
...normalized,
apiKey: config.kmaOpenApiKey
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
return upstream.body;
}
const payload = JSON.parse(upstream.body);
payload.query = { ...normalized };
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
});
app.get("/v1/han-river/water-level", async (request, reply) => {
let normalized;
@ -1267,6 +1588,97 @@ function buildServer({ env = process.env, provider = null } = {}) {
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/search", async (request, reply) => {
let normalized;
try {
normalized = normalizeKoreanStockSearchQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korean-stock-search",
q: normalized.q.toLowerCase(),
basDd: normalized.basDd,
market: normalized.market,
limit: normalized.limit
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let items;
try {
const result = await searchStocks({
query: normalized.q,
basDd: normalized.basDd,
market: normalized.market,
limit: normalized.limit,
apiKey: config.krxApiKey,
cache,
cacheTtlMs: config.cacheTtlMs
});
items = result.items;
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
const payload = {
items,
query: {
q: normalized.q,
bas_dd: normalized.basDd,
market: normalized.market,
limit: normalized.limit
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/neis/school-search", async (request, reply) => {
let normalized;
@ -1371,12 +1783,11 @@ function buildServer({ env = process.env, provider = null } = {}) {
return payload;
});
app.get("/v1/neis/school-meal", async (request, reply) => {
app.get("/v1/korean-stock/base-info", async (request, reply) => {
let normalized;
try {
normalized = normalizeNeisSchoolMealQuery(request.query || {});
normalized = normalizeKoreanStockLookupQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
@ -1386,7 +1797,7 @@ function buildServer({ env = process.env, provider = null } = {}) {
}
const cacheKey = makeCacheKey({
route: "neis-school-meal",
route: "korean-stock-base-info",
...normalized
});
const cached = cache.get(cacheKey);
@ -1408,6 +1819,11 @@ function buildServer({ env = process.env, provider = null } = {}) {
return {
error: "upstream_not_configured",
message: "KEDU_INFO_KEY is not configured on the proxy server.",
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
@ -1418,56 +1834,226 @@ function buildServer({ env = process.env, provider = null } = {}) {
};
}
const upstream = await proxyNeisSchoolMealRequest({
apiKey: config.keduInfoKey,
atptOfcdcScCode: normalized.atptOfcdcScCode,
sdSchulCode: normalized.sdSchulCode,
mlsvYmd: normalized.mlsvYmd,
mmealScCode: normalized.mmealScCode,
pIndex: normalized.pIndex,
pSize: normalized.pSize
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
const looksJson =
upstream.contentType.includes("json") ||
upstream.body.trimStart().startsWith("{") ||
upstream.body.trimStart().startsWith("[");
if (!looksJson) {
return upstream.body;
}
let payload;
let items;
try {
payload = JSON.parse(upstream.body);
} catch {
return upstream.body;
items = await fetchBaseInfo({
market: normalized.market,
basDd: normalized.basDd,
codeList: [normalized.code],
apiKey: config.krxApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
payload.proxy = {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
if (items.length === 0) {
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 을(를) 찾지 못했습니다.`
};
}
const payload = {
items,
item: items[0],
query: {
market: normalized.market,
code: normalized.code,
codes: [normalized.code],
bas_dd: normalized.basDd
},
requested_at: new Date().toISOString()
};
payload.query = {
education_office_code: normalized.atptOfcdcScCode,
school_code: normalized.sdSchulCode,
meal_date: normalized.mlsvYmd,
meal_kind_code: normalized.mmealScCode,
p_index: normalized.pIndex,
p_size: normalized.pSize
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/korean-stock/trade-info", async (request, reply) => {
let normalized;
try {
normalized = normalizeKoreanStockLookupQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "korean-stock-trade-info",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
if (!config.krxApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "KRX_API_KEY is not configured on the proxy server.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let items;
try {
items = await fetchTradeInfo({
market: normalized.market,
basDd: normalized.basDd,
codeList: [normalized.code],
apiKey: config.krxApiKey
});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
return {
error: error.code || "proxy_error",
message: error.message
};
}
if (items.length === 0) {
reply.code(404);
return {
error: "not_found",
message: `기준일 ${normalized.basDd}${normalized.market} 시장 종목 ${normalized.code} 의 일별 시세를 찾지 못했습니다.`
};
}
const payload = {
items,
item: items[0],
query: {
market: normalized.market,
code: normalized.code,
codes: [normalized.code],
bas_dd: normalized.basDd
},
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
app.get("/v1/household-waste/info", async (request, reply) => {
const query = request.query || {};
const sggNm = query["cond[SGG_NM::LIKE]"];
if (!sggNm || !sggNm.trim()) {
reply.code(400);
return {
error: "bad_request",
message: "cond[SGG_NM::LIKE] is required"
};
}
const pageNo = query.pageNo || "1";
const numOfRows = query.numOfRows || "20";
const cacheKey = makeCacheKey({
route: "household-waste-info",
sggNm: sggNm.trim(),
pageNo,
numOfRows
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const url = new URL("https://apis.data.go.kr/1741000/household_waste_info/info");
url.searchParams.set("serviceKey", config.molitApiKey);
url.searchParams.set("pageNo", pageNo);
url.searchParams.set("numOfRows", numOfRows);
url.searchParams.set("returnType", "json");
url.searchParams.set("cond[SGG_NM::LIKE]", sggNm.trim());
let upstreamData;
try {
const res = await fetch(url.toString());
if (!res.ok) {
reply.code(502);
return {
error: "upstream_error",
message: `Upstream responded with ${res.status}`,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
upstreamData = await res.json();
} catch (err) {
reply.code(502);
return {
error: "upstream_fetch_failed",
message: err.message,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const payload = {
...upstreamData,
query: { sgg_nm: sggNm.trim(), page_no: pageNo, num_of_rows: numOfRows },
proxy: {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
}
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
});
@ -1514,9 +2100,13 @@ if (require.main === module) {
module.exports = {
buildConfig,
buildServer,
convertLatLonToKmaGrid,
normalizeBlueRibbonNearbyQuery,
normalizeFineDustQuery,
normalizeHanRiverWaterLevelQuery,
normalizeKmaForecastQuery,
normalizeKoreanStockLookupQuery,
normalizeKoreanStockSearchQuery,
normalizeOpinetAroundQuery,
normalizeOpinetDetailQuery,
normalizeNeisSchoolMealQuery,
@ -1528,7 +2118,9 @@ module.exports = {
proxyHrfcoWaterLevelRequest,
proxyNeisSchoolMealRequest,
proxyNeisSchoolInfoRequest,
proxyKmaWeatherRequest,
proxyOpinetRequest,
proxySeoulSubwayRequest,
resolveLatestKmaForecastBase,
startServer
};

View file

@ -4,8 +4,9 @@ const assert = require("node:assert/strict");
const {
buildServer,
proxyAirKoreaRequest,
proxySeoulSubwayRequest,
proxyHrfcoWaterLevelRequest
proxyHrfcoWaterLevelRequest,
proxyKmaWeatherRequest,
proxySeoulSubwayRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
@ -30,7 +31,511 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
assert.equal(body.ok, true);
assert.equal(body.auth.tokenRequired, false);
assert.equal(body.upstreams.airKoreaConfigured, false);
assert.equal(body.upstreams.kmaOpenApiConfigured, false);
assert.equal(body.upstreams.krxConfigured, false);
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
assert.equal(body.upstreams.hrfcoConfigured, false);
});
test("health endpoint reports KRX upstream status when configured", async (t) => {
const app = buildServer({
env: {
KRX_API_KEY: "krx-key"
}
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/health"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().upstreams.krxConfigured, true);
});
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_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" }
}
);
}
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=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?query=%20%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90%20&date=20260404&limit=10"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls.length, 3);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().items[0].market, "KOSPI");
assert.equal(first.json().items[0].code, "005930");
assert.equal(first.json().items[0].name, "삼성전자");
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
assert.match(fetchCalls[0].url, /basDd=20260404/);
assert.equal(fetchCalls[0].headers.AUTH_KEY, "krx-key");
});
test("korean stock search rate limit does not trust spoofed cf-connecting-ip on direct requests", async (t) => {
const app = buildServer({
env: {
KSKILL_PROXY_RATE_LIMIT_MAX: "1"
}
});
t.after(async () => {
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "1.1.1.1"
}
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404",
headers: {
"cf-connecting-ip": "2.2.2.2"
}
});
assert.equal(first.statusCode, 503);
assert.equal(first.json().error, "upstream_not_configured");
assert.equal(second.statusCode, 429);
assert.equal(second.json().error, "rate_limited");
});
test("korean stock search returns healthy market results when another market upstream fails", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_isu_base_info")) {
return new Response("boom", {
status: 500,
statusText: "Internal Server Error"
});
}
if (text.includes("knx_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
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"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().items.length, 1);
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(fetchCalls.length, 3);
assert.ok(fetchCalls.every((entry) => entry.url.startsWith("https://data-dbg.krx.co.kr/")));
});
test("korean stock search reuses per-market base snapshots across different queries for the same date", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
global.fetch = async (url, options = {}) => {
const text = String(url);
fetchCalls.push({ url: text, headers: options.headers });
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("ksq_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" }
}
);
}
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 byKoreanName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=%EC%82%BC%EC%84%B1%EC%A0%84%EC%9E%90&bas_dd=20260404"
});
const byEnglishName = await app.inject({
method: "GET",
url: "/v1/korean-stock/search?q=Samsung&bas_dd=20260404"
});
assert.equal(byKoreanName.statusCode, 200);
assert.equal(byEnglishName.statusCode, 200);
assert.equal(byKoreanName.json().items[0].code, "005930");
assert.equal(byEnglishName.json().items[0].code, "005930");
assert.equal(fetchCalls.length, 3);
});
test("korean stock base-info endpoint returns 503 when proxy server lacks KRX API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-stock/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("korean stock base-info endpoint normalizes upstream KRX fields", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
let calledHeaders;
global.fetch = async (url, options = {}) => {
calledUrl = String(url);
calledHeaders = options.headers;
return new Response(
JSON.stringify({
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "대형주",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
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/base-info?market=KOSPI&code=005930&bas_dd=20260404"
});
assert.equal(response.statusCode, 200);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.match(calledUrl, /stk_isu_base_info/);
assert.match(calledUrl, /basDd=20260404/);
assert.equal(calledHeaders.AUTH_KEY, "krx-key");
assert.equal(response.json().item.code, "005930");
assert.equal(response.json().item.name, "삼성전자");
assert.equal(response.json().item.listed_shares, 5969782550);
});
test("korean stock trade-info endpoint caches successful responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
let calledUrl;
global.fetch = async (url) => {
fetchCalls += 1;
calledUrl = String(url);
return new Response(
JSON.stringify({
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "대형주",
TDD_CLSPRC: "84000",
CMPPREVDD_PRC: "1000",
FLUC_RT: "1.20",
TDD_OPNPRC: "83000",
TDD_HGPRC: "84500",
TDD_LWPRC: "82800",
ACC_TRDVOL: "12345678",
ACC_TRDVAL: "1030000000000",
MKTCAP: "500000000000000",
LIST_SHRS: "5969782550"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
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/trade-info?market=KOSPI&code=005930&bas_dd=20260404"
});
const second = await app.inject({
method: "GET",
url: "/v1/korean-stock/trade-info?market=KOSPI&stockCode=005930&date=20260404"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.ok(calledUrl.startsWith("https://data-dbg.krx.co.kr/"));
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(first.json().item.close_price, 84000);
assert.equal(first.json().item.trading_value, 1030000000000);
});
test("korean stock trade-info endpoint does not relabel an unmatched single-row upstream response", 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: [
{
BAS_DD: "20260404",
ISU_CD: "KR7000660001",
ISU_NM: "하이트진로",
MKT_NM: "KOSPI",
SECT_TP_NM: "중형주",
TDD_CLSPRC: "21000"
}
]
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
}
if (text.includes("stk_isu_base_info")) {
return new Response(
JSON.stringify({
OutBlock_1: []
}),
{
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"
}
});
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.equal(fetchCalls.length, 2);
assert.ok(fetchCalls.every((entry) => entry.startsWith("https://data-dbg.krx.co.kr/")));
});
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
@ -329,6 +834,222 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
assert.match(String(url), /getVilageFcst/);
assert.match(String(url), /base_date=20260405/);
assert.match(String(url), /base_time=0500/);
assert.match(String(url), /nx=60/);
assert.match(String(url), /ny=127/);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: [
{
baseDate: "20260405",
baseTime: "0500",
category: "TMP",
fcstDate: "20260405",
fcstTime: "0600",
fcstValue: "14",
nx: 60,
ny: 127
}
]
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
},
now: () => new Date("2026-04-05T06:30:00+09:00")
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=37.5665&lon=126.978"
});
const second = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.deepEqual(first.json().query, {
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 1,
numOfRows: 1000,
dataType: "JSON"
});
});
test("korea weather endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
response: {
header: {
resultCode: "00",
resultMsg: "NORMAL_SERVICE"
},
body: {
dataType: "JSON",
items: {
item: []
}
}
}
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127&baseDate=20260405&baseTime=0500"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().response.header.resultCode, "00");
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /serviceKey=kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
});
test("korea weather endpoint rejects out-of-range coordinates before reaching upstream", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
throw new Error("fetch should not be called for invalid coordinates");
};
const app = buildServer({
env: {
KMA_OPEN_API_KEY: "kma-key"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?lat=91&lon=126.978"
});
assert.equal(response.statusCode, 400);
assert.deepEqual(response.json(), {
error: "bad_request",
message: "Provide valid lat and lon."
});
assert.equal(fetchCalls, 0);
});
test("korea weather endpoint returns 503 when proxy server lacks KMA API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korea-weather/forecast?nx=60&ny=127"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("proxyKmaWeatherRequest injects API key and preserves caller query params", async () => {
let calledUrl;
const result = await proxyKmaWeatherRequest({
baseDate: "20260405",
baseTime: "0500",
nx: 60,
ny: 127,
pageNo: 2,
numOfRows: 50,
dataType: "JSON",
apiKey: "test-kma-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.ok(calledUrl.startsWith("https://apis.data.go.kr/"));
assert.match(calledUrl, /\/1360000\/VilageFcstInfoService_2\.0\/getVilageFcst\?/);
assert.match(calledUrl, /serviceKey=test-kma-key/);
assert.match(calledUrl, /base_date=20260405/);
assert.match(calledUrl, /base_time=0500/);
assert.match(calledUrl, /nx=60/);
assert.match(calledUrl, /ny=127/);
assert.match(calledUrl, /pageNo=2/);
assert.match(calledUrl, /numOfRows=50/);
assert.match(calledUrl, /dataType=JSON/);
});
test("han river water-level endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
@ -604,6 +1325,48 @@ const SAMPLE_APT_TRADE_XML = `<?xml version="1.0" encoding="UTF-8"?>
</body>
</response>`;
const SAMPLE_KRX_BASE_INFO = {
OutBlock_1: [
{
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
ISU_ABBRV: "삼성전자",
ISU_ENG_NM: "Samsung Electronics",
LIST_DD: "19750611",
MKT_TP_NM: "KOSPI",
SECUGRP_NM: "주권",
SECT_TP_NM: "전기전자",
KIND_STKCERT_TP_NM: "보통주",
PARVAL: "100",
LIST_SHRS: "5,919,638,922"
}
]
};
const SAMPLE_KRX_TRADE_INFO = {
OutBlock_1: [
{
BAS_DD: "20260404",
ISU_CD: "KR7005930003",
ISU_SRT_CD: "005930",
ISU_NM: "삼성전자",
MKT_NM: "KOSPI",
SECT_TP_NM: "전기전자",
TDD_CLSPRC: "85,000",
CMPPREVDD_PRC: "1,200",
FLUC_RT: "1.43",
TDD_OPNPRC: "84,100",
TDD_HGPRC: "85,400",
TDD_LWPRC: "83,900",
ACC_TRDVOL: "12,345,678",
ACC_TRDVAL: "1,045,678,900,000",
MKTCAP: "503,169,308,370,000",
LIST_SHRS: "5,919,638,922"
}
]
};
test("real estate region-code endpoint returns matching codes", async (t) => {
const app = buildServer();

View file

@ -0,0 +1,79 @@
# market-kurly-search
마켓컬리 웹이 실제로 사용하는 **비로그인 검색/상품 상세 표면**을 사용해 상품 후보와 현재 가격을 조회하는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install market-kurly-search
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 로그인 없이 확인 가능한 공개 웹 표면만 사용합니다.
- 현재 가격은 `discountedPrice` 가 있으면 그 값을, 없으면 `salesPrice` 를 사용합니다.
- 가격/품절/배송 문구는 시점에 따라 달라질 수 있으므로 조회 시각 기준 참고값으로만 답해야 합니다.
- 장바구니/주문/주소 기반 배송 가능 여부 같은 회원/액션 기능은 범위 밖입니다.
## 사용 예시
```js
const { countProducts, getProductDetail, searchProducts } = require("market-kurly-search")
async function main() {
const searchResult = await searchProducts("우유")
const detailResult = await getProductDetail(searchResult.items[0].productNo)
const countResult = await countProducts("우유")
console.log(countResult)
console.log(searchResult.items[0])
console.log(detailResult)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
```
## 공개 API
- `searchProducts(keyword, options?)`
- `countProducts(keyword, options?)`
- `getProductDetail(productNo, options?)`
## Live smoke snapshot
2026-04-09 기준 live smoke test 에서 아래 공개 표면이 로그인 없이 응답했습니다.
```json
{
"count": {
"query": "우유",
"count": 468
},
"firstSearchItem": {
"productNo": 5063110,
"name": "[연세우유 x 마켓컬리] 전용목장우유 900mL",
"currentPrice": 2780,
"isSoldOut": false,
"goodsUrl": "https://www.kurly.com/goods/5063110"
},
"detail": {
"productNo": 5063110,
"name": "[연세우유 x 마켓컬리] 전용목장우유 900mL",
"currentPrice": 2780,
"deliveryTypeNames": [
"샛별배송(내일 아침)"
]
}
}
```

View file

@ -0,0 +1,32 @@
{
"name": "market-kurly-search",
"version": "0.1.0",
"description": "Unauthenticated Market Kurly product search and detail 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",
"kurly",
"market-kurly",
"retail",
"price"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,93 @@
const {
KURLY_WEB_BASE_URL,
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeKeyword,
normalizeProductNo,
normalizeSearchResponse
} = require("./parse")
const KURLY_API_BASE_URL = "https://api.kurly.com"
const DEFAULT_BROWSER_HEADERS = {
accept: "application/json, text/plain, */*",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
}
async function request(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: options.method || "GET",
headers: {
...DEFAULT_BROWSER_HEADERS,
...(options.headers || {})
},
signal: options.signal
})
if (!response.ok) {
throw new Error(`Market Kurly request failed with ${response.status} for ${url}`)
}
return response
}
async function requestJson(url, options = {}) {
const response = await request(url, options)
return response.json()
}
async function requestText(url, options = {}) {
const response = await request(url, {
...options,
headers: {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
...(options.headers || {})
}
})
return response.text()
}
async function searchProducts(keyword, options = {}) {
const normalizedKeyword = normalizeKeyword(keyword)
const url = new URL(`${KURLY_API_BASE_URL}/search/v4/sites/market/normal-search`)
url.searchParams.set("keyword", normalizedKeyword)
url.searchParams.set("page", String(options.page || 1))
const payload = await requestJson(url.toString(), options)
return normalizeSearchResponse(payload, normalizedKeyword)
}
async function countProducts(keyword, options = {}) {
const normalizedKeyword = normalizeKeyword(keyword)
const url = new URL(`${KURLY_API_BASE_URL}/search/v3/sites/market/normal-search/count`)
url.searchParams.set("keyword", normalizedKeyword)
url.searchParams.set("filters", options.filters || "")
url.searchParams.set("allow_replace", options.allowReplace === false ? "false" : "true")
const payload = await requestJson(url.toString(), options)
return normalizeCountResponse(payload, normalizedKeyword)
}
async function getProductDetail(productNo, options = {}) {
const normalizedProductNo = normalizeProductNo(productNo)
const html = await requestText(`${KURLY_WEB_BASE_URL}/goods/${normalizedProductNo}`, options)
const nextData = extractNextDataJson(html)
return findProductDetail(nextData)
}
module.exports = {
countProducts,
getProductDetail,
searchProducts
}

View file

@ -0,0 +1,224 @@
const KURLY_WEB_BASE_URL = "https://www.kurly.com"
const SEARCH_EMPTY_RESULT_ERROR = "No Market Kurly product candidates were returned."
const COUNT_EMPTY_RESULT_ERROR = "No Market Kurly result count was returned."
const DETAIL_EMPTY_RESULT_ERROR = "No Market Kurly product detail was returned."
const NEXT_DATA_MISSING_ERROR = "Market Kurly goods page did not include __NEXT_DATA__."
function toNumberOrNull(value) {
if (value === undefined || value === null || value === "") {
return null
}
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function normalizeKeyword(query) {
const normalized = String(query || "").trim()
if (!normalized) {
throw new Error("keyword is required.")
}
return normalized
}
function normalizeProductNo(productNo) {
const normalized = String(productNo || "").trim()
if (!normalized) {
throw new Error("productNo is required.")
}
const numeric = Number(normalized)
return Number.isFinite(numeric) ? numeric : normalized
}
function buildGoodsUrl(productNo) {
return `${KURLY_WEB_BASE_URL}/goods/${productNo}`
}
function firstPresent(...values) {
for (const value of values) {
if (value !== undefined && value !== null && value !== "") {
return value
}
}
return null
}
function normalizeDeliveryTypes(value) {
if (!Array.isArray(value)) {
return []
}
return value.map((item) => String(item || "").trim()).filter(Boolean)
}
function normalizeSearchItem(item) {
const productNo = normalizeProductNo(item?.no)
const salesPrice = toNumberOrNull(item?.salesPrice)
const discountedPrice = toNumberOrNull(item?.discountedPrice)
const basePrice = toNumberOrNull(item?.basePrice)
const currentPrice = firstPresent(discountedPrice, salesPrice, basePrice)
const originalPrice = firstPresent(salesPrice, basePrice, currentPrice)
return {
productNo,
name: String(item?.name || ""),
shortDescription: item?.shortDescription || null,
currentPrice,
originalPrice,
salesPrice,
discountedPrice,
discountRate: toNumberOrNull(item?.discountRate),
isSoldOut: Boolean(item?.isSoldOut),
isPurchaseStatus: item?.isPurchaseStatus ?? null,
deliveryTypeNames: normalizeDeliveryTypes(item?.deliveryTypeNames),
reviewCount: toNumberOrNull(item?.reviewCount),
imageUrl: item?.listImageUrl || item?.imageUrl || null,
goodsUrl: buildGoodsUrl(productNo),
raw: item
}
}
function collectSearchItems(payload) {
const sections = Array.isArray(payload?.data?.listSections) ? payload.data.listSections : []
const items = []
for (const section of sections) {
if (Array.isArray(section?.data?.items)) {
items.push(...section.data.items)
}
}
return items
}
function normalizeSearchResponse(payload, query) {
const items = collectSearchItems(payload)
if (items.length === 0) {
throw new Error(SEARCH_EMPTY_RESULT_ERROR)
}
return {
query: normalizeKeyword(query),
pagination: payload?.data?.meta?.pagination || null,
items: items.map(normalizeSearchItem)
}
}
function normalizeCountResponse(payload, query) {
const count = toNumberOrNull(payload?.data?.count)
if (count === null) {
throw new Error(COUNT_EMPTY_RESULT_ERROR)
}
return {
query: normalizeKeyword(query),
count
}
}
function extractNextDataJson(html) {
const match = String(html || "").match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/u)
if (!match) {
throw new Error(NEXT_DATA_MISSING_ERROR)
}
return JSON.parse(match[1])
}
function hasDetailShape(candidate) {
return Boolean(candidate) && typeof candidate === "object" && "name" in candidate && "isSoldOut" in candidate && "deliveryTypeNames" in candidate && ("no" in candidate || "productNo" in candidate)
}
function normalizeDetailCandidate(candidate) {
const productNo = normalizeProductNo(firstPresent(candidate.productNo, candidate.no))
const showablePrices = candidate?.showablePrices || {}
const basePrice = toNumberOrNull(firstPresent(
candidate.retailPrice,
showablePrices.retailPrice,
candidate.basePrice,
showablePrices.basePrice
))
const salesPrice = toNumberOrNull(firstPresent(candidate.salesPrice, showablePrices.salesPrice))
const rawDiscountedPrice = toNumberOrNull(firstPresent(
candidate.discountedPrice,
showablePrices.discountedPrice,
showablePrices.couponDiscountedPrice
))
const discountedPrice = rawDiscountedPrice ?? (
basePrice !== null && salesPrice !== null && basePrice > salesPrice
? salesPrice
: null
)
const currentPrice = firstPresent(discountedPrice, salesPrice, basePrice)
const originalPrice = firstPresent(basePrice, salesPrice, currentPrice)
return {
productNo,
name: String(candidate.name || ""),
shortDescription: candidate.shortDescription || null,
currentPrice,
originalPrice,
basePrice,
salesPrice,
discountedPrice,
discountRate: toNumberOrNull(candidate.discountRate),
isSoldOut: Boolean(candidate.isSoldOut),
deliveryTypeNames: normalizeDeliveryTypes(candidate.deliveryTypeNames),
imageUrl: firstPresent(
candidate.imageUrl,
candidate.listImageUrl,
candidate.productVerticalMediumUrl,
candidate.mainImageUrl
),
goodsUrl: buildGoodsUrl(productNo),
raw: candidate
}
}
function findProductDetail(nextData) {
const stack = [nextData]
while (stack.length > 0) {
const current = stack.pop()
if (Array.isArray(current)) {
stack.push(...current)
continue
}
if (!current || typeof current !== "object") {
continue
}
if (hasDetailShape(current)) {
return normalizeDetailCandidate(current)
}
stack.push(...Object.values(current))
}
throw new Error(DETAIL_EMPTY_RESULT_ERROR)
}
module.exports = {
COUNT_EMPTY_RESULT_ERROR,
DETAIL_EMPTY_RESULT_ERROR,
KURLY_WEB_BASE_URL,
NEXT_DATA_MISSING_ERROR,
SEARCH_EMPTY_RESULT_ERROR,
buildGoodsUrl,
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeKeyword,
normalizeProductNo,
normalizeSearchResponse
}

View file

@ -0,0 +1,224 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { countProducts, getProductDetail, searchProducts } = require("../src/index")
const {
extractNextDataJson,
findProductDetail,
normalizeCountResponse,
normalizeSearchResponse
} = require("../src/parse")
const searchPayload = {
success: true,
message: null,
data: {
meta: {
pagination: {
total: 2,
count: 2,
perPage: 96,
currentPage: 1,
totalPages: 1
},
actualKeyword: "딸기"
},
listSections: [
{
view: {
sectionCode: "PRODUCT_LIST",
version: "v1"
},
data: {
items: [
{
no: 5048935,
name: "금실 딸기 2종",
shortDescription: "새콤달콤 제철 딸기",
listImageUrl: "https://product-image.kurly.com/example-1.jpg",
salesPrice: 13900,
discountedPrice: 9900,
discountRate: 28.0,
isSoldOut: false,
deliveryTypeNames: ["샛별배송"],
reviewCount: 321,
isPurchaseStatus: true
},
{
no: 1234,
name: "냉동 딸기 1kg",
shortDescription: "스무디용 냉동 딸기",
listImageUrl: "https://product-image.kurly.com/example-2.jpg",
salesPrice: 8900,
discountedPrice: null,
discountRate: 0,
isSoldOut: true,
deliveryTypeNames: ["택배배송"],
reviewCount: 12,
isPurchaseStatus: false
}
]
}
}
]
}
}
const countPayload = {
data: {
count: 468
}
}
const detailHtml = `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({
props: {
pageProps: {
product: {
no: 5063110,
name: "[연세우유 x 마켓컬리] 전용목장우유 900mL",
shortDescription: "가격, 퀄리티 모두 만족스러운 1A등급 우유",
basePrice: 2780,
salesPrice: 2780,
discountedPrice: null,
discountRate: 0,
isSoldOut: false,
deliveryTypeNames: ["샛별배송(내일 아침)"],
imageUrl: "https://product-image.kurly.com/example-detail.jpg"
}
}
}
})}</script></body></html>`
const discountedDetailHtml = `<!doctype html><html><head></head><body><script id="__NEXT_DATA__" type="application/json">${JSON.stringify({
props: {
pageProps: {
product: {
productNo: 5048935,
name: "금실 딸기 2종",
shortDescription: "새콤달콤 제철 딸기",
basePrice: 9900,
retailPrice: 13900,
discountRate: 28,
isSoldOut: false,
deliveryTypeNames: ["샛별배송"],
showablePrices: {
salesPrice: 9900,
basePrice: null,
retailPrice: 13900,
couponDiscountedPrice: null
},
mainImageUrl: "https://img-cf.kurly.com/shop/data/goods/1581671553838l0.jpg"
}
}
}
})}</script></body></html>`
test("normalizeSearchResponse returns public Market Kurly product candidates", () => {
const result = normalizeSearchResponse(searchPayload, "딸기")
assert.equal(result.query, "딸기")
assert.equal(result.pagination.total, 2)
assert.equal(result.items[0].productNo, 5048935)
assert.equal(result.items[0].currentPrice, 9900)
assert.equal(result.items[0].originalPrice, 13900)
assert.equal(result.items[0].discountRate, 28)
assert.equal(result.items[0].isSoldOut, false)
assert.equal(result.items[0].goodsUrl, "https://www.kurly.com/goods/5048935")
assert.deepEqual(result.items[0].deliveryTypeNames, ["샛별배송"])
assert.equal(result.items[1].currentPrice, 8900)
})
test("normalizeCountResponse extracts the numeric result count", () => {
assert.deepEqual(normalizeCountResponse(countPayload, "우유"), {
query: "우유",
count: 468
})
})
test("extractNextDataJson and findProductDetail parse the goods page payload", () => {
const nextData = extractNextDataJson(detailHtml)
const detail = findProductDetail(nextData)
assert.equal(detail.productNo, 5063110)
assert.equal(detail.name, "[연세우유 x 마켓컬리] 전용목장우유 900mL")
assert.equal(detail.currentPrice, 2780)
assert.equal(detail.originalPrice, 2780)
assert.equal(detail.isSoldOut, false)
assert.deepEqual(detail.deliveryTypeNames, ["샛별배송(내일 아침)"])
assert.equal(detail.goodsUrl, "https://www.kurly.com/goods/5063110")
})
test("findProductDetail normalizes discounted goods page payloads", () => {
const nextData = extractNextDataJson(discountedDetailHtml)
const detail = findProductDetail(nextData)
assert.equal(detail.productNo, 5048935)
assert.equal(detail.currentPrice, 9900)
assert.equal(detail.originalPrice, 13900)
assert.equal(detail.basePrice, 13900)
assert.equal(detail.salesPrice, 9900)
assert.equal(detail.discountedPrice, 9900)
assert.equal(detail.imageUrl, "https://img-cf.kurly.com/shop/data/goods/1581671553838l0.jpg")
assert.deepEqual(detail.deliveryTypeNames, ["샛별배송"])
})
test("public client helpers consume injected fetch fixtures", async () => {
const originalFetch = global.fetch
const seen = []
global.fetch = async (url) => {
seen.push(String(url))
if (String(url).includes("/search/v4/sites/market/normal-search")) {
return makeJsonResponse(searchPayload)
}
if (String(url).includes("/search/v3/sites/market/normal-search/count")) {
return makeJsonResponse(countPayload)
}
if (String(url).includes("/goods/5063110")) {
return new Response(detailHtml, {
status: 200,
headers: {
"content-type": "text/html; charset=utf-8"
}
})
}
return new Response("not found", { status: 404 })
}
try {
const searchResult = await searchProducts("딸기")
assert.equal(searchResult.items[0].productNo, 5048935)
const countResult = await countProducts("우유")
assert.equal(countResult.count, 468)
const detailResult = await getProductDetail(5063110)
assert.equal(detailResult.productNo, 5063110)
assert.equal(detailResult.currentPrice, 2780)
assert.ok(seen.some((url) => url.includes("keyword=%EB%94%B8%EA%B8%B0")))
assert.ok(seen.some((url) => url.includes("allow_replace=true")))
assert.ok(seen.some((url) => url.endsWith("/goods/5063110")))
} finally {
global.fetch = originalFetch
}
})
test("searchProducts validates the keyword before sending the request", async () => {
await assert.rejects(() => searchProducts(" "), /keyword is required\./)
await assert.rejects(() => countProducts(""), /keyword is required\./)
await assert.rejects(() => getProductDetail(""), /productNo is required\./)
})
function makeJsonResponse(payload) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json; charset=utf-8"
}
})
}

View file

@ -0,0 +1,14 @@
"use strict";
const bundled = require("../korean-character-count/scripts/korean_character_count.js");
if (require.main === module) {
try {
bundled.main(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = bundled;

View file

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

View file

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

13
scripts/patent_search.py Normal file
View file

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

View file

@ -221,7 +221,7 @@ 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 k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
/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/,
);
});
@ -282,7 +282,43 @@ test("repository docs advertise the korean-spell-check skill and usage constrain
assert.match(roadmap, /한국어 맞춤법 검사 스킬 출시/);
});
test("repository docs advertise the MFDS public-health skills and mandatory symptom interview", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const drugSkillPath = path.join(repoRoot, "mfds-drug-safety", "SKILL.md");
const foodSkillPath = path.join(repoRoot, "mfds-food-safety", "SKILL.md");
const drugFeaturePath = path.join(repoRoot, "docs", "features", "mfds-drug-safety.md");
const foodFeaturePath = path.join(repoRoot, "docs", "features", "mfds-food-safety.md");
assert.ok(fs.existsSync(drugSkillPath), "expected mfds-drug-safety/SKILL.md to exist");
assert.ok(fs.existsSync(foodSkillPath), "expected mfds-food-safety/SKILL.md to exist");
assert.ok(fs.existsSync(drugFeaturePath), "expected docs/features/mfds-drug-safety.md to exist");
assert.ok(fs.existsSync(foodFeaturePath), "expected docs/features/mfds-food-safety.md to exist");
assert.match(readme, /\| 의약품 안전 체크 \|/);
assert.match(readme, /\| 식품 안전 체크 \|/);
assert.match(install, /--skill mfds-drug-safety/);
assert.match(install, /--skill mfds-food-safety/);
assert.match(sources, /15075057\/openapi\.do/);
assert.match(sources, /15097208\/openapi\.do/);
assert.match(sources, /15056516\/openapi\.do/);
assert.match(sources, /15074318\/openapi\.do/);
assert.match(sources, /foodsafetykorea\.go\.kr\/api\/openApiInfo\.do.*svc_no=I0490/);
for (const relativePath of [
path.join("mfds-drug-safety", "SKILL.md"),
path.join("mfds-food-safety", "SKILL.md"),
path.join("docs", "features", "mfds-drug-safety.md"),
path.join("docs", "features", "mfds-food-safety.md")
]) {
const doc = read(relativePath);
assert.match(doc, /인터뷰|되묻/);
assert.match(doc, /호흡곤란/);
assert.match(doc, /직접 진단|진단\/처방|진단\)이나/);
assert.match(doc, /119|응급실/);
}
});
test("used-car-price-search docs document the provider survey and SK direct surface", () => {
const skill = read(path.join("used-car-price-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "used-car-price-search.md"));
@ -350,6 +386,50 @@ test("seoul subway docs require an explicit proxy until the hosted route is live
assert.doesNotMatch(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
});
test("repository docs advertise the korea-weather 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", "korea-weather.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korea-weather.md to exist");
assert.match(readme, /\| 한국 날씨 조회 \|/);
assert.match(readme, /\[한국 날씨 조회 가이드\]\(docs\/features\/korea-weather\.md\)/);
assert.match(install, /--skill korea-weather/);
assert.match(roadmap, /한국 날씨 조회 스킬 출시/);
assert.match(sources, /기상청 단기예보 조회서비스: https:\/\/www\.data\.go\.kr\/data\/15084084\/openapi\.do/);
});
test("korea-weather docs route short-term forecast calls through the proxy without requiring a user API key", () => {
const skillPath = path.join(repoRoot, "korea-weather", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected korea-weather/SKILL.md to exist");
const skill = read(path.join("korea-weather", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "korea-weather.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
assert.match(skill, /^name: korea-weather$/m);
assert.match(skill, /^description: .*날씨.*기상청.*프록시.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /\/v1\/korea-weather\/forecast/);
assert.match(doc, /기상청.*단기예보|단기예보.*기상청/);
assert.match(doc, /사용자가 .*API key.*직접.*필요(가|는)? 없다|개인 API key 없이/i);
assert.match(doc, /nx|ny|위도|경도/u);
assert.match(doc, /TMP|SKY|PTY|POP/);
assert.match(doc, /KSKILL_PROXY_BASE_URL|k-skill-proxy\.nomadamas\.org/);
assert.doesNotMatch(doc, /KMA_OPEN_API_KEY=.*사용자/);
}
assert.match(proxyDoc, /GET \/v1\/korea-weather\/forecast/);
assert.match(proxyDoc, /KMA_OPEN_API_KEY/);
assert.match(proxyReadme, /GET \/v1\/korea-weather\/forecast/);
assert.match(proxyReadme, /KMA_OPEN_API_KEY/);
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
@ -433,6 +513,32 @@ test("ktx-booking helper python regression tests pass", () => {
);
});
test("repository docs advertise the subway-lost-property skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "subway-lost-property.md");
const skillPath = path.join(repoRoot, "subway-lost-property", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/subway-lost-property.md to exist");
assert.ok(fs.existsSync(skillPath), "expected subway-lost-property/SKILL.md to exist");
assert.match(readme, /\| 지하철 분실물 조회 \|/);
assert.match(readme, /\[지하철 분실물 조회 가이드\]\(docs\/features\/subway-lost-property\.md\)/);
assert.match(install, /--skill subway-lost-property/);
});
test("subway-lost-property docs lock the official LOST112 guidance flow", () => {
const skill = read(path.join("subway-lost-property", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "subway-lost-property.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /LOST112/);
assert.match(doc, /seoulmetro\.co\.kr\/kr\/page\.do\?menuIdx=541/);
assert.match(doc, /python3 scripts\/subway_lost_property\.py/);
assert.match(doc, /SITE=V/);
assert.match(doc, /안내형|하이브리드/);
}
});
test("repository docs advertise the zipcode-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -448,40 +554,41 @@ test("repository docs advertise the zipcode-search skill across the documented s
assert.match(sources, /우체국 도로명주소 검색: https:\/\/parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
});
test("zipcode-search docs lock the official ePost extraction flow and reliable transport example", () => {
test("zipcode-search docs lock the official postcode plus English-address extraction flow", () => {
const skillPath = path.join(repoRoot, "zipcode-search", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
const skill = read(path.join("zipcode-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "zipcode-search.md"));
const readme = read("README.md");
const sources = read(path.join("docs", "sources.md"));
assert.match(skill, /^name: zipcode-search$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
assert.match(doc, /sch_zipcode/);
assert.match(doc, /sch_address1/);
assert.match(doc, /sch_bdNm/);
assert.match(doc, /https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
assert.match(doc, /viewDetail/);
assert.match(doc, /English\/집배코드/);
assert.match(doc, /Rep\. of KOREA/);
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
assert.match(doc, /--max-time/);
assert.match(doc, /"--retry",\s+"3"/);
assert.match(doc, /--retry-all-errors/);
assert.match(doc, /"--retry-delay",\s+"1"/);
assert.match(doc, /영문 주소|영문주소/);
assert.match(doc, /python3 scripts\/zipcode_search\.py/);
assert.match(doc, /\.\/scripts\/zipcode_search\.py/);
assert.match(doc, /mktemp|임시 파일/);
assert.match(doc, /curl: \(23\)/);
assert.match(doc, /짧은 도로명 \+ 건물번호/);
assert.match(doc, /시\/군\/구 포함 전체 주소/);
assert.doesNotMatch(doc, /urllib\.request/);
assert.doesNotMatch(doc, /urlopen/);
}
assert.match(readme, /우편번호 \+ 공식 영문주소 조회/);
assert.match(sources, /우체국 통합 우편번호\/영문주소 검색: https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
assert.match(skill, /검색 결과가 없으면/i);
assert.doesNotMatch(skill, /timeout\s*=/);
assert.doesNotMatch(featureDoc, /timeout\s*=/);
assert.match(skill, /`curl` 자체 제한/);
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
assert.match(featureDoc, /`curl` 자체 제한/);
});
test("repository docs advertise the delivery-tracking skill across the documented surfaces", () => {
@ -749,6 +856,54 @@ test("daiso-product-search docs record the shipped feature and official sources"
assert.match(sources, /https:\/\/www\.daisomall\.co\.kr\/api\/pd\/pdh\/selStrPkupStck/);
});
test("repository docs advertise the market-kurly-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "market-kurly-search.md");
const skillPath = path.join(repoRoot, "market-kurly-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/market-kurly-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected market-kurly-search/SKILL.md to exist");
assert.match(readme, /\| 마켓컬리 상품 조회 \|/);
assert.match(readme, /\[마켓컬리 상품 조회 가이드\]\(docs\/features\/market-kurly-search\.md\)/);
assert.match(install, /--skill market-kurly-search/);
assert.match(install, /npm install -g .* market-kurly-search/);
assert.match(roadmap, /마켓컬리 상품 조회 스킬 출시/);
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
assert.match(sources, /https:\/\/api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
assert.match(sources, /https:\/\/www\.kurly\.com\/goods\/5063110/);
});
test("market-kurly-search skill and docs describe the unauthenticated Kurly search and detail flow", () => {
const skill = read(path.join("market-kurly-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "market-kurly-search.md"));
assert.match(skill, /^name: market-kurly-search$/m);
assert.match(skill, /^description: .*마켓컬리.*상품.*가격.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /api\.kurly\.com\/search\/v4\/sites\/market\/normal-search/);
assert.match(doc, /api\.kurly\.com\/search\/v3\/sites\/market\/normal-search\/count/);
assert.match(doc, /www\.kurly\.com\/goods\/<productNo>|www\.kurly\.com\/goods\/5063110/);
assert.match(doc, /로그인 없이|비로그인/);
assert.match(doc, /현재 가격|할인/);
assert.match(doc, /품절 여부|판매 상태/);
assert.match(doc, /가격.*달라질 수|시점에 따라 달라질 수/u);
assert.match(doc, /주문|장바구니/);
assert.match(doc, /보수적으로|보수적/);
}
});
test("market-kurly-search package exposes reusable search/count/detail helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "market-kurly-search", "src", "index.js"));
assert.equal(typeof pkg.searchProducts, "function");
assert.equal(typeof pkg.countProducts, "function");
assert.equal(typeof pkg.getProductDetail, "function");
});
test("repository docs advertise the olive-young-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -819,6 +974,70 @@ test("olive-young-search skill documents the upstream daiso CLI flow for stores,
}
});
test("repository docs advertise the bunjang-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "bunjang-search.md");
const skillPath = path.join(repoRoot, "bunjang-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/bunjang-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected bunjang-search/SKILL.md to exist");
assert.match(readme, /\| 번개장터 검색 \|/);
assert.match(readme, /\[번개장터 검색 가이드\]\(docs\/features\/bunjang-search\.md\)/);
assert.match(install, /--skill bunjang-search/);
assert.match(install, /npm install -g .* bunjang-cli/);
assert.match(roadmap, /번개장터 검색 스킬 출시/);
assert.match(sources, /https:\/\/www\.npmjs\.com\/package\/bunjang-cli/);
assert.match(sources, /https:\/\/github\.com\/pinion05\/bunjangcli/);
});
test("bunjang-search skill documents bunjang-cli search, detail, favorite, chat, and AI export flows", () => {
const skill = read(path.join("bunjang-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "bunjang-search.md"));
const install = read(path.join("docs", "install.md"));
assert.match(skill, /^name: bunjang-search$/m);
assert.match(skill, /^description: .*번개장터.*검색.*상세.*찜.*채팅.*$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /bunjang-cli/);
assert.match(doc, /pinion05\/bunjangcli/);
assert.match(doc, /npx --yes bunjang-cli --help/);
assert.match(doc, /npx --yes bunjang-cli search /);
assert.match(doc, /item get/);
assert.match(doc, /favorite add/);
assert.match(doc, /favorite remove/);
assert.match(doc, /favorite list/);
assert.match(doc, /chat list/);
assert.match(doc, /chat start/);
assert.match(doc, /chat send/);
assert.match(doc, /--start-page/);
assert.match(doc, /--pages/);
assert.match(doc, /--max-items/);
assert.match(doc, /--with-detail/);
assert.match(doc, /--output/);
assert.match(doc, /--ai/);
assert.match(doc, /TOON|toon/i);
assert.match(doc, /TTY|interactive/);
assert.match(doc, /로그인.*선택적|선택적.*로그인/u);
assert.match(
doc,
/검색 결과.*(제목.?가격|가격.?제목).*(1차|우선)|title.?price.*(triage|first)/i,
);
assert.match(
doc,
/(description|status|location).*(item get|--with-detail).*(전|먼저|이후)|((item get|--with-detail).*(description|status|location).*(전|먼저|이후))/i,
);
assert.match(doc, /노이즈|noisy|불안정|rely on/i);
}
assert.match(install, /### `bunjang-search` upstream CLI quickstart/);
assert.match(install, /npx --yes bunjang-cli --help/);
assert.match(install, /npx --yes bunjang-cli search "아이폰"/);
assert.match(install, /npx --yes bunjang-cli --json item get/);
});
test("repository docs advertise the coupang-product-search skill", () => {
const readme = read("README.md");
@ -852,6 +1071,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
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 kleague-results/);
@ -923,8 +1143,9 @@ test("repository docs advertise the blue-ribbon-nearby skill across the document
const featureDocPath = path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/blue-ribbon-nearby.md to exist");
assert.match(readme, /\| 근처 블루리본 맛집 \|/);
assert.match(readme, /\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \|/);
assert.match(readme, /\[근처 블루리본 맛집 가이드\]\(docs\/features\/blue-ribbon-nearby\.md\)/);
assert.match(readme, /블루리본 측이 `www\.bluer\.co\.kr` 에 자동화 접근 전면 차단/);
assert.match(install, /--skill blue-ribbon-nearby/);
assert.match(roadmap, /근처 블루리본 맛집 스킬 출시/);
assert.match(sources, /블루리본 지역 검색: https:\/\/www\.bluer\.co\.kr\/search\/zone/);
@ -1117,6 +1338,26 @@ test("repository docs advertise the toss-securities skill across the documented
assert.match(sources, /tossinvest-cli: https:\/\/github\.com\/JungHoonGhae\/tossinvest-cli/);
});
test("repository docs advertise the hipass-receipt 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 setup = read(path.join("docs", "setup.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "hipass-receipt.md");
const skillPath = path.join(repoRoot, "hipass-receipt", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/hipass-receipt.md to exist");
assert.ok(fs.existsSync(skillPath), "expected hipass-receipt/SKILL.md to exist");
assert.match(readme, /\| 하이패스 영수증 발급 \|/);
assert.match(readme, /\[하이패스 영수증 발급 가이드\]\(docs\/features\/hipass-receipt\.md\)/);
assert.match(install, /--skill hipass-receipt/);
assert.match(setup, /하이패스 영수증 발급 \| 사용자 시크릿 불필요 \(로그인된 브라우저 세션 필요\)/);
assert.match(roadmap, /하이패스 영수증 발급 스킬 출시/);
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/main\.do/);
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/html\/guide\/siteguide_6\.jsp/);
});
test("toss-securities skill documents the tossctl install, auth, and read-only workflow", () => {
const skillPath = path.join(repoRoot, "toss-securities", "SKILL.md");
@ -1140,6 +1381,32 @@ test("toss-securities skill documents the tossctl install, auth, and read-only w
}
});
test("hipass-receipt skill documents the logged-in browser session contract", () => {
const skillPath = path.join(repoRoot, "hipass-receipt", "SKILL.md");
const packageReadmePath = path.join(repoRoot, "packages", "hipass-receipt", "README.md");
assert.ok(fs.existsSync(skillPath), "expected hipass-receipt/SKILL.md to exist");
assert.ok(fs.existsSync(packageReadmePath), "expected packages/hipass-receipt/README.md to exist");
const skill = read(path.join("hipass-receipt", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "hipass-receipt.md"));
const packageReadme = read(path.join("packages", "hipass-receipt", "README.md"));
assert.match(skill, /^name: hipass-receipt$/m);
assert.match(skill, /로그인은 반드시 사용자가 직접 해야 한다/);
assert.match(skill, /Playwright persistent context|user-data-dir/);
assert.match(skill, /세션이 만료되면 즉시 중단하고 다시 로그인/);
assert.match(featureDoc, /20분/);
assert.match(featureDoc, /영수증선택출력|영수증전체출력/);
assert.match(featureDoc, /로그인된 브라우저 세션에서만 동작/);
assert.match(featureDoc, /playwright-core/);
assert.match(skill, /--encrypted-card-number/);
assert.match(packageReadme, /buildUsageHistoryQuery/);
assert.match(packageReadme, /parseUsageHistoryList/);
assert.match(packageReadme, /inspectHipassPage/);
assert.match(packageReadme, /playwright-core/);
});
test("toss-securities package exposes safe read-only tossctl helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "toss-securities", "src", "index.js"));
@ -1152,6 +1419,16 @@ test("toss-securities package exposes safe read-only tossctl helpers", () => {
assert.equal(typeof pkg.listWatchlist, "function");
});
test("hipass-receipt package exposes fixture-friendly query, parse, and session helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "hipass-receipt", "src", "index.js"));
assert.equal(pkg.HIPASS_ENDPOINTS.loginPage, "https://www.hipass.co.kr/comm/lginpg.do");
assert.equal(typeof pkg.buildUsageHistoryQuery, "function");
assert.equal(typeof pkg.parseUsageHistoryList, "function");
assert.equal(typeof pkg.inspectHipassPage, "function");
assert.equal(typeof pkg.buildReceiptRequest, "function");
});
test("toss-securities package README stays aligned with the read-only tossctl wrapper contract", () => {
const packageReadme = read(path.join("packages", "toss-securities", "README.md"));
@ -1163,10 +1440,40 @@ test("toss-securities package README stays aligned with the read-only tossctl wr
assert.match(packageReadme, /지원하지 않음|not supported/u);
});
test("hipass-receipt package README and npm metadata stay aligned with the helper contract", () => {
const packageReadme = read(path.join("packages", "hipass-receipt", "README.md"));
const packageJson = readJson(path.join("packages", "hipass-receipt", "package.json"));
assert.equal(packageJson.name, "hipass-receipt");
assert.match(packageJson.description, /Hi-Pass/);
assert.ok(packageJson.files.includes("test/fixtures"));
assert.match(packageReadme, /logged-in browser session/i);
assert.match(packageReadme, /Playwright/);
assert.equal(typeof packageJson.dependencies?.["playwright-core"], "string");
assert.match(packageReadme, /playwright-core/);
assert.match(packageReadme, /buildReceiptRequest/);
assert.match(packageReadme, /test\/fixtures\/usage-history-list\.html/);
});
test("hipass-receipt pack dry-run ships fixture-demo assets for the published README workflow", () => {
const packResult = JSON.parse(
childProcess.execFileSync("npm", ["pack", "--workspace", "hipass-receipt", "--json", "--dry-run"], {
cwd: repoRoot,
encoding: "utf8"
}),
);
const files = packResult[0]?.files?.map((entry) => entry.path) || [];
assert.ok(files.includes("test/fixtures/usage-history-list.html"));
assert.ok(files.includes("test/fixtures/login-page.html"));
assert.ok(files.includes("README.md"));
});
test("pack:dry-run includes the toss-securities workspace", () => {
const packageJson = JSON.parse(read("package.json"));
assert.match(packageJson.scripts["pack:dry-run"], /workspace toss-securities/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace hipass-receipt/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace used-car-price-search/);
});
@ -1324,6 +1631,79 @@ test("joseon-sillok-search install payload includes the documented helper comman
}
});
test("repository docs advertise the korean-patent-search skill and official KIPRIS Plus API setup", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-patent-search.md");
const featureDoc = read(path.join("docs", "features", "korean-patent-search.md"));
const skillPath = path.join(repoRoot, "korean-patent-search", "SKILL.md");
const skill = read(path.join("korean-patent-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-patent-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-patent-search/SKILL.md to exist");
assert.match(readme, /\| 한국 특허 정보 검색 \|/);
assert.match(readme, /\[한국 특허 정보 검색 가이드\]\(docs\/features\/korean-patent-search\.md\)/);
assert.match(install, /--skill korean-patent-search/);
assert.match(install, /KIPRIS_PLUS_API_KEY/);
assert.match(install, /python3 scripts\/patent_search\.py --query "배터리"/);
assert.match(setup, /한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`/);
assert.match(security, /KIPRIS_PLUS_API_KEY/);
assert.match(setupSkill, /한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`/);
assert.match(examplesSecrets, /^KIPRIS_PLUS_API_KEY=replace-me$/m);
assert.match(skill, /^name: korean-patent-search$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /KIPRIS Plus/i);
assert.match(doc, /getWordSearch/);
assert.match(doc, /getBibliographyDetailInfoSearch/);
assert.match(doc, /ServiceKey/);
assert.match(doc, /python3 scripts\/patent_search\.py/);
assert.match(doc, /Done when/i);
assert.doesNotMatch(doc, /packages\/korean-patent-search/);
assert.doesNotMatch(doc, /python-packages\/korean-patent-search/);
}
assert.match(sources, /https:\/\/plus\.kipris\.or\.kr\/portal\/data\/service\/List\.do\?subTab=SC001&entYn=N&menuNo=200100/);
assert.match(sources, /https:\/\/www\.data\.go\.kr\/data\/15058788\/openapi\.do/);
assert.match(roadmap, /한국 특허 정보 검색 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-patent-search")),
"expected no repo workspace to be added for korean-patent-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-patent-search")), false);
});
test("korean-patent-search install payload includes the documented helper command", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "korean-patent-search-"));
const installedSkillPath = path.join(tempRoot, "korean-patent-search");
const bundledHelperPath = path.join(installedSkillPath, "scripts", "patent_search.py");
try {
fs.cpSync(path.join(repoRoot, "korean-patent-search"), installedSkillPath, { recursive: true });
assert.ok(fs.existsSync(bundledHelperPath), "expected korean-patent-search/scripts/patent_search.py to exist");
const helpText = childProcess.execFileSync("python3", ["scripts/patent_search.py", "--help"], {
cwd: installedSkillPath,
encoding: "utf8",
});
assert.match(helpText, /Search Korean patent information via the official KIPRIS Plus Open API/);
assert.match(helpText, /--query/);
assert.match(helpText, /--application-number/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("repository docs advertise the real-estate-search skill and proxy-based approach", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1388,6 +1768,75 @@ test("real-estate-search skill uses proxy endpoints not MCP self-host", () => {
}
});
test("repository docs advertise the korean-stock-search skill and proxy-backed KRX approach", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-stock-search.md");
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
const skillPath = path.join(repoRoot, "korean-stock-search", "SKILL.md");
const skill = read(path.join("korean-stock-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-stock-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-stock-search/SKILL.md to exist");
assert.match(readme, /\| 한국 주식 정보 조회 \|/);
assert.match(readme, /\[한국 주식 정보 조회 가이드\]\(docs\/features\/korean-stock-search\.md\)/);
assert.match(install, /--skill korean-stock-search/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /\/v1\/korean-stock\/search/);
assert.match(doc, /\/v1\/korean-stock\/base-info/);
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
assert.match(doc, /KRX_API_KEY/);
assert.match(doc, /사용자.*KRX_API_KEY.*(불필요|준비할 필요가 없)/u);
assert.doesNotMatch(doc, /packages\/korean-stock-search/);
assert.doesNotMatch(doc, /python-packages\/korean-stock-search/);
}
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /KRX_API_KEY/);
}
for (const doc of [proxyReadme, proxyDoc]) {
assert.match(doc, /\/v1\/korean-stock\/search/);
assert.match(doc, /\/v1\/korean-stock\/base-info/);
assert.match(doc, /\/v1\/korean-stock\/trade-info/);
}
assert.match(sources, /korea-stock-mcp: https:\/\/github\.com\/jjlabsio\/korea-stock-mcp/);
assert.match(roadmap, /한국 주식 정보 조회 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-stock-search")),
"expected no repo workspace to be added for korean-stock-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-stock-search")), false);
});
test("korean-stock-search skill stays proxy-first and does not require local MCP install", () => {
const featureDoc = read(path.join("docs", "features", "korean-stock-search.md"));
const skill = read(path.join("korean-stock-search", "SKILL.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /k-skill-proxy\.nomadamas\.org\/v1\/korean-stock/);
assert.match(doc, /curl/);
assert.match(doc, /proxy.*서버.*KRX_API_KEY|KRX_API_KEY.*proxy.*서버/u);
assert.doesNotMatch(doc, /npx\s+(?:-y|--yes)\s+korea-stock-mcp/);
assert.doesNotMatch(doc, /codex mcp add/);
assert.doesNotMatch(doc, /claude_desktop_config\.json/);
assert.doesNotMatch(doc, /DART_API_KEY/);
}
});
test("repository docs advertise the shipped korean-spell-check helper assets", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1400,6 +1849,96 @@ test("repository docs advertise the shipped korean-spell-check helper assets", (
assert.match(install, /python3 scripts\/korean_spell_check\.py/);
});
test("repository docs advertise the korean-character-count skill and deterministic counting contract", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-character-count.md");
const featureDoc = read(path.join("docs", "features", "korean-character-count.md"));
const skillPath = path.join(repoRoot, "korean-character-count", "SKILL.md");
const skill = read(path.join("korean-character-count", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-character-count.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-character-count/SKILL.md to exist");
assert.match(readme, /\| 한국어 글자 수 세기 \|/);
assert.match(readme, /\[한국어 글자 수 세기 가이드\]\(docs\/features\/korean-character-count\.md\)/);
assert.match(install, /--skill korean-character-count/);
assert.match(install, /node scripts\/korean_character_count\.js --text "가나다"/);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /grapheme|extended grapheme/i);
assert.match(doc, /UTF-8/);
assert.match(doc, /NEIS/i);
assert.match(doc, /CRLF|U\+2028|U\+2029/);
assert.match(doc, /node scripts\/korean_character_count\.js/);
assert.doesNotMatch(doc, /packages\/korean-character-count/);
assert.doesNotMatch(doc, /python-packages\/korean-character-count/);
}
assert.match(sources, /https:\/\/www\.unicode\.org\/reports\/tr29\//);
assert.match(sources, /https:\/\/encoding\.spec\.whatwg\.org\//);
assert.match(sources, /https:\/\/nodejs\.org\/api\/buffer\.html/);
assert.match(roadmap, /한국어 글자 수 세기 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-character-count")),
"expected no repo workspace to be added for korean-character-count",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-character-count")), false);
});
test("korean-character-count feature doc NEIS example matches live helper output", () => {
const featureDoc = read(path.join("docs", "features", "korean-character-count.md"));
const helperOutput = childProcess.execFileSync(
"node",
[
"scripts/korean_character_count.js",
"--text",
"첫 줄\n둘째 줄🙂",
"--profile",
"neis",
"--format",
"text",
],
{ cwd: repoRoot, encoding: "utf8" },
);
const bytesMatch = helperOutput.match(/^bytes:\s+(\d+)$/m);
assert.ok(bytesMatch, `expected helper text output to include a bytes line, got: ${helperOutput}`);
assert.equal(bytesMatch[1], "23");
assert.match(featureDoc, new RegExp(String.raw`bytes:\s+${bytesMatch[1]}`));
assert.match(featureDoc, /bytes=23/);
});
test("korean-character-count install payload includes the documented helper command", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "korean-character-count-"));
const installedSkillPath = path.join(tempRoot, "korean-character-count");
const bundledHelperPath = path.join(installedSkillPath, "scripts", "korean_character_count.js");
try {
fs.cpSync(path.join(repoRoot, "korean-character-count"), installedSkillPath, { recursive: true });
assert.ok(
fs.existsSync(bundledHelperPath),
"expected korean-character-count/scripts/korean_character_count.js to exist",
);
const helpText = childProcess.execFileSync("node", ["scripts/korean_character_count.js", "--help"], {
cwd: installedSkillPath,
encoding: "utf8",
});
assert.match(helpText, /--profile/);
assert.match(helpText, /default/);
assert.match(helpText, /neis/i);
assert.match(helpText, /--stdin/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("repository docs advertise the cheap-gas-nearby skill and Opinet key requirements", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1494,3 +2033,60 @@ test("repository docs advertise the han-river-water-level skill and rollout-pend
assert.match(sources, /api\.hrfco\.go\.kr/);
assert.match(roadmap, /한강 수위 정보 조회 스킬 출시/);
});
test("repository docs advertise the MFDS drug and food safety skills", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const drugFeatureDocPath = path.join(repoRoot, "docs", "features", "mfds-drug-safety.md");
const foodFeatureDocPath = path.join(repoRoot, "docs", "features", "mfds-food-safety.md");
const drugSkillPath = path.join(repoRoot, "mfds-drug-safety", "SKILL.md");
const foodSkillPath = path.join(repoRoot, "mfds-food-safety", "SKILL.md");
assert.equal(fs.existsSync(drugFeatureDocPath), true);
assert.equal(fs.existsSync(foodFeatureDocPath), true);
assert.equal(fs.existsSync(drugSkillPath), true);
assert.equal(fs.existsSync(foodSkillPath), true);
assert.match(readme, /\| 의약품 안전 체크 \|/);
assert.match(readme, /\| 식품 안전 체크 \|/);
assert.match(readme, /\[의약품 안전 체크 가이드\]\(docs\/features\/mfds-drug-safety\.md\)/);
assert.match(readme, /\[식품 안전 체크 가이드\]\(docs\/features\/mfds-food-safety\.md\)/);
assert.match(install, /--skill mfds-drug-safety/);
assert.match(install, /--skill mfds-food-safety/);
assert.match(sources, /15075057\/openapi\.do/);
assert.match(sources, /15097208\/openapi\.do/);
assert.match(sources, /15056516\/openapi\.do/);
assert.match(sources, /foodsafetykorea\.go\.kr\/api\/openApiInfo\.do/);
});
test("MFDS public-health skill docs require interview-first safety flow and official endpoints", () => {
const drugSkill = read(path.join("mfds-drug-safety", "SKILL.md"));
const foodSkill = read(path.join("mfds-food-safety", "SKILL.md"));
const drugFeatureDoc = read(path.join("docs", "features", "mfds-drug-safety.md"));
const foodFeatureDoc = read(path.join("docs", "features", "mfds-food-safety.md"));
const sources = read(path.join("docs", "sources.md"));
for (const doc of [drugSkill, drugFeatureDoc]) {
assert.match(doc, /증상.*바로 단정하지 말고.*먼저 되묻/);
assert.match(doc, /호흡곤란|의식저하|심한 발진/);
assert.match(doc, /DrbEasyDrugInfoService\/getDrbEasyDrugList/);
assert.match(doc, /SafeStadDrugService\/getSafeStadDrugInq/);
assert.match(doc, /DATA_GO_KR_API_KEY/);
assert.match(doc, /python3 scripts\/mfds_drug_safety\.py/);
}
for (const doc of [foodSkill, foodFeatureDoc]) {
assert.match(doc, /증상.*바로 단정하지 말고.*먼저 되묻/);
assert.match(doc, /혈변|탈수|호흡곤란/);
assert.match(doc, /PrsecImproptFoodInfoService03\/getPrsecImproptFoodList01/);
assert.match(doc, /I0490/);
assert.match(doc, /DATA_GO_KR_API_KEY/);
assert.match(doc, /python3 scripts\/mfds_food_safety\.py/);
assert.match(doc, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
assert.doesNotMatch(doc, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
}
assert.match(sources, /https:\/\/openapi\.foodsafetykorea\.go\.kr\/api\/sample\/I0490\/json\/1\/5/);
assert.doesNotMatch(sources, /http:\/\/openapi\.foodsafetykorea\.go\.kr/);
});

18
scripts/subway_lost_property.py Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = (
Path(__file__).resolve().parent.parent
/ "subway-lost-property"
/ "scripts"
/ "subway_lost_property.py"
)
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled subway lost-property helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -0,0 +1,112 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
countLines,
countNeisBytes,
countUtf8Bytes,
createReport,
parseArgs,
} = require("./korean_character_count.js");
test("createReport counts graphemes, lines, and bytes with the default contract", () => {
const sample = "한🙂\r\n둘째 줄";
const report = createReport(sample);
assert.equal(report.profile, "default");
assert.equal(report.counts.characters, 7);
assert.equal(report.counts.characters_without_whitespace, 5);
assert.equal(report.counts.lines, 2);
assert.equal(report.counts.bytes, countUtf8Bytes(sample));
assert.equal(report.counts.bytes_utf8, report.counts.bytes);
assert.equal(report.counts.bytes_neis, countNeisBytes(sample));
assert.match(report.contract.characters, /grapheme/i);
assert.match(report.contract.bytes, /UTF-8/);
});
test("countLines treats CRLF, CR, LF, and Unicode separators as one line break each", () => {
assert.equal(countLines(""), 0);
assert.equal(countLines("가"), 1);
assert.equal(countLines("가\n"), 2);
assert.equal(countLines("가\r\n나\r다\u2028라\u2029마"), 5);
});
test("countNeisBytes applies Hangul 3-byte, ASCII 1-byte, and newline 2-byte rules", () => {
assert.equal(countNeisBytes("가A 1\n나🙂"), 15);
assert.equal(countNeisBytes("ABC"), 3);
assert.equal(countNeisBytes("한글"), 6);
});
test("countNeisBytes falls back to UTF-8 bytes for non-Hangul graphemes", () => {
assert.equal(countUtf8Bytes("\u0301"), 2);
assert.equal(countNeisBytes("\u0301"), 2);
assert.equal(countNeisBytes("🙂"), countUtf8Bytes("🙂"));
});
test("parseArgs enforces one input source and validates the profile", () => {
assert.deepEqual(parseArgs(["--text", "가나다"]), {
format: "json",
inputMode: "text",
profile: "default",
text: "가나다",
});
assert.throws(() => parseArgs(["--text", "가", "--file", "sample.txt"]), /exactly one input source/i);
assert.throws(() => parseArgs(["--text", "가", "--text", "나"]), /exactly one input source/i);
assert.throws(() => parseArgs(["--profile", "legacy", "--text", "가"]), /unknown profile/i);
});
test("CLI accepts text, file, and stdin input", () => {
const repoRoot = path.join(__dirname, "..");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "korean-character-count-cli-"));
const samplePath = path.join(tempDir, "sample.txt");
try {
fs.writeFileSync(samplePath, "가나다\nABC", "utf8");
const textOutput = JSON.parse(
childProcess.execFileSync("node", ["scripts/korean_character_count.js", "--text", "가나다", "--format", "json"], {
cwd: repoRoot,
encoding: "utf8",
}),
);
assert.equal(textOutput.counts.characters, 3);
assert.equal(textOutput.counts.bytes_utf8, 9);
const fileOutput = JSON.parse(
childProcess.execFileSync("node", ["scripts/korean_character_count.js", "--file", samplePath, "--format", "json"], {
cwd: repoRoot,
encoding: "utf8",
}),
);
assert.equal(fileOutput.counts.lines, 2);
assert.equal(fileOutput.counts.bytes_utf8, countUtf8Bytes("가나다\nABC"));
const stdinOutput = JSON.parse(
childProcess.execFileSync("node", ["scripts/korean_character_count.js", "--stdin", "--profile", "neis"], {
cwd: repoRoot,
encoding: "utf8",
input: "가나다\nABC",
}),
);
assert.equal(stdinOutput.profile, "neis");
assert.equal(stdinOutput.counts.bytes, countNeisBytes("가나다\nABC"));
const duplicateText = childProcess.spawnSync(
"node",
["scripts/korean_character_count.js", "--text", "가나다", "--text", "라마바", "--format", "json"],
{
cwd: repoRoot,
encoding: "utf8",
},
);
assert.notEqual(duplicateText.status, 0);
assert.match(duplicateText.stderr, /exactly one input source/i);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,79 @@
import unittest
from scripts.mfds_drug_safety import (
build_drug_interview,
normalize_easy_drug_item,
normalize_safe_stad_item,
resolve_service_key,
)
class DrugInterviewTest(unittest.TestCase):
def test_build_drug_interview_requires_followup_questions_and_red_flags(self):
interview = build_drug_interview(
question="타이레놀이랑 판콜 같이 먹어도 되나요?",
symptoms="두드러기와 어지러움",
)
self.assertEqual(interview["domain"], "drug")
self.assertIn("누가 복용하려는지", interview["must_ask"][0])
self.assertTrue(any("얼마나" in item for item in interview["must_ask"]))
self.assertTrue(any("복용 중인 약" in item for item in interview["must_ask"]))
self.assertTrue(any("알레르기" in item for item in interview["must_ask"]))
self.assertTrue(any("호흡곤란" in item for item in interview["red_flags"]))
self.assertTrue(any("의식" in item for item in interview["red_flags"]))
self.assertIn("즉시 119", interview["urgent_action"])
class DrugNormalizationTest(unittest.TestCase):
def test_normalize_easy_drug_item_extracts_public_safety_summary(self):
item = normalize_easy_drug_item(
{
"itemName": "타이레놀정160밀리그램",
"entpName": "한국얀센",
"efcyQesitm": "감기로 인한 발열 및 동통에 사용합니다.",
"useMethodQesitm": "만 12세 이상은 필요시 복용합니다.",
"atpnWarnQesitm": "매일 세 잔 이상 술을 마시는 사람은 전문가와 상의하십시오.",
"atpnQesitm": "간질환 환자는 주의하십시오.",
"intrcQesitm": "다른 해열진통제와 함께 복용하지 마십시오.",
"seQesitm": "발진, 구역이 나타날 수 있습니다.",
"depositMethodQesitm": "실온 보관하십시오.",
}
)
self.assertEqual(item["source"], "drug_easy_info")
self.assertEqual(item["item_name"], "타이레놀정160밀리그램")
self.assertEqual(item["company_name"], "한국얀센")
self.assertIn("발열", item["efficacy"])
self.assertIn("해열진통제", item["interactions"])
self.assertIn("실온", item["storage"])
def test_normalize_safe_stad_item_extracts_store_medicine_fields(self):
item = normalize_safe_stad_item(
{
"PRDLST_NM": "어린이타이레놀현탁액",
"BSSH_NM": "한국존슨앤드존슨판매(유)",
"EFCY_QESITM": "해열 및 진통",
"USE_METHOD_QESITM": "용법에 따라 복용",
"ATPN_WARN_QESITM": "과량복용 주의",
"INTRC_QESITM": "다른 아세트아미노펜 제제와 병용 주의",
"SE_QESITM": "드물게 발진",
}
)
self.assertEqual(item["source"], "safe_standby_medicine")
self.assertEqual(item["item_name"], "어린이타이레놀현탁액")
self.assertIn("아세트아미노펜", item["interactions"])
class ServiceKeyResolutionTest(unittest.TestCase):
def test_resolve_service_key_requires_data_go_kr_api_key(self):
with self.assertRaisesRegex(ValueError, "DATA_GO_KR_API_KEY"):
resolve_service_key(None, env={})
self.assertEqual(resolve_service_key("abc", env={}), "abc")
self.assertEqual(resolve_service_key(None, env={"DATA_GO_KR_API_KEY": "xyz"}), "xyz")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,120 @@
import unittest
from unittest import mock
from scripts.mfds_food_safety import (
ApiError,
FOOD_RECALL_LIVE_URL,
FOOD_RECALL_SAMPLE_URL,
_request_json,
build_food_interview,
filter_food_items,
normalize_food_recall_row,
normalize_improper_food_item,
resolve_data_go_service_key,
)
class FoodInterviewTest(unittest.TestCase):
def test_build_food_interview_requires_symptom_followup_and_red_flags(self):
interview = build_food_interview(
question="이 김밥 먹어도 되나요?",
symptoms="복통과 설사",
)
self.assertEqual(interview["domain"], "food")
self.assertTrue(any("언제" in item for item in interview["must_ask"]))
self.assertTrue(any("얼마나" in item for item in interview["must_ask"]))
self.assertTrue(any("기저질환" in item for item in interview["must_ask"]))
self.assertTrue(any("알레르기" in item for item in interview["must_ask"]))
self.assertTrue(any("혈변" in item for item in interview["red_flags"]))
self.assertTrue(any("탈수" in item for item in interview["red_flags"]))
self.assertIn("응급실", interview["urgent_action"])
class FoodNormalizationTest(unittest.TestCase):
def test_normalize_food_recall_row_keeps_official_recall_fields(self):
item = normalize_food_recall_row(
{
"PRDLST_NM": "맛있는김밥",
"BSSH_NM": "예시식품",
"RTRVLPRVNS": "대장균 기준 규격 부적합",
"CRET_DTM": "2026-04-07 18:03:56.058442",
"DISTBTMLMT": "2027-12-18",
"PRDLST_TYPE": "가공식품",
}
)
self.assertEqual(item["source"], "foodsafetykorea_recall")
self.assertEqual(item["product_name"], "맛있는김밥")
self.assertEqual(item["company_name"], "예시식품")
self.assertIn("대장균", item["reason"])
self.assertEqual(item["category"], "가공식품")
def test_normalize_improper_food_item_keeps_official_improper_food_fields(self):
item = normalize_improper_food_item(
{
"PRDUCT": "예시 유부초밥",
"ENTRPS": "예시푸드",
"IMPROPT_ITM": "황색포도상구균",
"INSPCT_RESULT": "기준 부적합",
"FOOD_TY": "즉석조리식품",
"REGIST_DT": "2026-04-08",
}
)
self.assertEqual(item["source"], "mfds_improper_food")
self.assertEqual(item["product_name"], "예시 유부초밥")
self.assertEqual(item["company_name"], "예시푸드")
self.assertIn("황색포도상구균", item["reason"])
def test_filter_food_items_matches_product_and_company_names(self):
items = [
{"product_name": "맛있는김밥", "company_name": "예시식품"},
{"product_name": "사과주스", "company_name": "김밥나라"},
]
by_product = filter_food_items(items, "김밥")
by_company = filter_food_items(items, "나라")
self.assertEqual(len(by_product), 1)
self.assertEqual(by_product[0]["product_name"], "맛있는김밥")
self.assertEqual(len(by_company), 1)
self.assertEqual(by_company[0]["company_name"], "김밥나라")
class FoodServiceKeyResolutionTest(unittest.TestCase):
def test_resolve_data_go_service_key_requires_data_go_kr_api_key(self):
with self.assertRaisesRegex(ValueError, "DATA_GO_KR_API_KEY"):
resolve_data_go_service_key(None, env={})
self.assertEqual(resolve_data_go_service_key("abc", env={}), "abc")
self.assertEqual(resolve_data_go_service_key(None, env={"DATA_GO_KR_API_KEY": "xyz"}), "xyz")
class FoodRecallTransportTest(unittest.TestCase):
def test_food_recall_urls_use_https(self):
self.assertTrue(FOOD_RECALL_SAMPLE_URL.startswith("https://"))
self.assertTrue(FOOD_RECALL_LIVE_URL.startswith("https://"))
def test_request_json_turns_invalid_foodsafety_key_html_into_api_error(self):
class FakeResponse:
headers = {"Content-Type": "text/html;charset=utf-8"}
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return "<html><script>alert('invalid key');</script></html>".encode("utf-8")
url = FOOD_RECALL_LIVE_URL.format(api_key="invalid-demo-key", start=1, end=1)
with mock.patch("scripts.mfds_food_safety.urllib.request.urlopen", return_value=FakeResponse()):
with self.assertRaisesRegex(ApiError, "foodsafetykorea-key"):
_request_json(url)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,359 @@
import contextlib
import io
import unittest
from unittest import mock
from scripts.patent_search import (
PatentDetail,
PatentSearchResponse,
PatentSearchResult,
build_detail_params,
build_search_params,
fetch_xml,
get_patent_detail,
main,
parse_args,
parse_patent_detail_response,
parse_patent_search_response,
resolve_service_key,
search_patents,
)
SAMPLE_SEARCH_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>00</resultCode>
<resultMsg>NORMAL SERVICE</resultMsg>
</header>
<body>
<items>
<item>
<indexNo>1</indexNo>
<registerStatus>공개</registerStatus>
<inventionTitle>이차 전지 배터리 </inventionTitle>
<ipcNumber>H01M 10/00</ipcNumber>
<registerNumber>1023456789000</registerNumber>
<registerDate>2024/01/15 00:00:00</registerDate>
<applicationNumber>1020240001234</applicationNumber>
<applicationDate>2024/01/02 00:00:00</applicationDate>
<openNumber>1020250005678</openNumber>
<openDate>2025/07/09 00:00:00</openDate>
<publicationNumber>1020250005678</publicationNumber>
<publicationDate>2025/07/09 00:00:00</publicationDate>
<astrtCont>배터리 수명 향상을 위한 관리 구조.</astrtCont>
<bigDrawing>http://example.com/big.png</bigDrawing>
<drawing>http://example.com/thumb.png</drawing>
<applicantName>주식회사 오픈에이아이코리아</applicantName>
</item>
<item>
<indexNo>2</indexNo>
<registerStatus>등록</registerStatus>
<inventionTitle>배터리 모듈 고정장치</inventionTitle>
<ipcNumber>H01M 50/20</ipcNumber>
<applicationNumber>1020240009999</applicationNumber>
<applicationDate>2024/02/18 00:00:00</applicationDate>
<astrtCont>모듈 조립성을 높이는 고정장치.</astrtCont>
<applicantName>주식회사 샘플</applicantName>
</item>
</items>
<numOfRows>2</numOfRows>
<pageNo>1</pageNo>
<totalCount>24</totalCount>
</body>
</response>
"""
SAMPLE_DETAIL_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>00</resultCode>
<resultMsg>NORMAL SERVICE</resultMsg>
</header>
<body>
<item>
<applicationNumber>1020240001234</applicationNumber>
<inventionTitle>이차 전지 배터리 </inventionTitle>
<registerStatus>공개</registerStatus>
<applicationDate>2024/01/02 00:00:00</applicationDate>
<openNumber>1020250005678</openNumber>
<openDate>2025/07/09 00:00:00</openDate>
<publicationNumber>1020250005678</publicationNumber>
<publicationDate>2025/07/09 00:00:00</publicationDate>
<registerNumber>1023456789000</registerNumber>
<registerDate>2024/01/15 00:00:00</registerDate>
<ipcNumber>H01M 10/00</ipcNumber>
<applicantName>주식회사 오픈에이아이코리아</applicantName>
<astrtCont>배터리 수명 향상을 위한 관리 구조.</astrtCont>
<drawing>http://example.com/thumb.png</drawing>
<bigDrawing>http://example.com/big.png</bigDrawing>
</item>
</body>
</response>
"""
SAMPLE_AUTH_ERROR_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>10</resultCode>
<resultMsg>API KEY를 잘못 입력하셨습니다.(SERVICE KEY IS NOT REGISTERED ERROR.[30])</resultMsg>
</header>
</response>
"""
class ParsePatentSearchResponseTest(unittest.TestCase):
def test_parses_items_and_paging_metadata(self):
report = parse_patent_search_response(SAMPLE_SEARCH_XML, query="배터리")
self.assertIsInstance(report, PatentSearchResponse)
self.assertEqual(report.query, "배터리")
self.assertEqual(report.total_count, 24)
self.assertEqual(report.page_no, 1)
self.assertEqual(report.num_of_rows, 2)
self.assertEqual(len(report.items), 2)
self.assertIsInstance(report.items[0], PatentSearchResult)
self.assertEqual(report.items[0].application_number, "1020240001234")
self.assertEqual(report.items[0].invention_title, "이차 전지 배터리 팩")
self.assertEqual(report.items[0].abstract_text, "배터리 수명 향상을 위한 열 관리 구조.")
self.assertEqual(report.items[0].applicant_name, "주식회사 오픈에이아이코리아")
class ParsePatentDetailResponseTest(unittest.TestCase):
def test_parses_detail_item(self):
detail = parse_patent_detail_response(SAMPLE_DETAIL_XML)
self.assertIsInstance(detail, PatentDetail)
self.assertEqual(detail.application_number, "1020240001234")
self.assertEqual(detail.invention_title, "이차 전지 배터리 팩")
self.assertEqual(detail.register_status, "공개")
self.assertEqual(detail.big_drawing, "http://example.com/big.png")
class RequestBuilderTest(unittest.TestCase):
def test_build_search_params_include_service_key_and_paging(self):
params = build_search_params(
query="배터리",
year=2024,
page_no=2,
num_of_rows=5,
patent=True,
utility=False,
service_key="test-key",
)
self.assertEqual(params["word"], "배터리")
self.assertEqual(params["year"], "2024")
self.assertEqual(params["patent"], "true")
self.assertEqual(params["utility"], "false")
self.assertEqual(params["pageNo"], "2")
self.assertEqual(params["numOfRows"], "5")
self.assertEqual(params["ServiceKey"], "test-key")
def test_build_detail_params_only_requires_application_number_and_service_key(self):
params = build_detail_params(application_number="1020240001234", service_key="test-key")
self.assertEqual(params, {"applicationNumber": "1020240001234", "ServiceKey": "test-key"})
def test_build_search_params_requires_at_least_one_document_type(self):
with self.assertRaisesRegex(ValueError, "At least one of patent or utility"):
build_search_params(
query="배터리",
patent=False,
utility=False,
service_key="test-key",
)
class ServiceKeyEncodingTest(unittest.TestCase):
def test_resolve_service_key_accepts_percent_encoded_portal_value(self):
self.assertEqual(resolve_service_key("abc%2Bdef%3D%3D"), "abc+def==")
def test_resolve_service_key_decodes_percent_encoded_env_value(self):
with mock.patch.dict(
"scripts.patent_search.os.environ",
{"KIPRIS_PLUS_API_KEY": "abc%2Bdef%3D%3D"},
clear=True,
):
self.assertEqual(resolve_service_key(), "abc+def==")
def test_fetch_xml_does_not_double_encode_percent_encoded_service_key(self):
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b"<response><header><resultCode>00</resultCode></header></response>"
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["timeout"] = timeout
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_search_params(query="배터리", service_key=resolve_service_key("abc%2Bdef%3D%3D")),
timeout=7,
)
self.assertEqual(captured["timeout"], 7)
self.assertIn("ServiceKey=abc%2Bdef%3D%3D", captured["url"])
self.assertNotIn("%252B", captured["url"])
self.assertNotIn("%253D", captured["url"])
def test_build_search_params_decodes_percent_encoded_service_key(self):
"""Callers passing a raw percent-encoded key directly into build_search_params
must not trigger double-encoding when urlencode serializes the dict."""
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b"<response><header><resultCode>00</resultCode></header></response>"
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_search_params(query="배터리", service_key="abc%2Bdef%3D%3D"),
)
self.assertIn("ServiceKey=abc%2Bdef%3D%3D", captured["url"])
self.assertNotIn("%252B", captured["url"])
self.assertNotIn("%253D", captured["url"])
def test_build_detail_params_decodes_percent_encoded_service_key(self):
"""Same guard for build_detail_params direct callers."""
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b"<response><header><resultCode>00</resultCode></header></response>"
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_detail_params(application_number="1020240001234", service_key="abc%2Bdef%3D%3D"),
)
self.assertIn("ServiceKey=abc%2Bdef%3D%3D", captured["url"])
self.assertNotIn("%252B", captured["url"])
self.assertNotIn("%253D", captured["url"])
class PatentSearchWorkflowTest(unittest.TestCase):
def test_search_patents_uses_fetcher_and_returns_parsed_report(self):
calls = []
def fake_fetcher(url, params, timeout):
calls.append((url, params, timeout))
return SAMPLE_SEARCH_XML
report = search_patents("배터리", service_key="test-key", fetcher=fake_fetcher, page_no=3, num_of_rows=7)
self.assertEqual(report.page_no, 1)
self.assertEqual(report.items[0].application_number, "1020240001234")
self.assertTrue(calls[0][0].endswith("/getWordSearch"))
self.assertEqual(calls[0][1]["ServiceKey"], "test-key")
self.assertEqual(calls[0][1]["pageNo"], "3")
self.assertEqual(calls[0][1]["numOfRows"], "7")
def test_get_patent_detail_uses_detail_endpoint(self):
calls = []
def fake_fetcher(url, params, timeout):
calls.append((url, params, timeout))
return SAMPLE_DETAIL_XML
detail = get_patent_detail("1020240001234", service_key="test-key", fetcher=fake_fetcher)
self.assertEqual(detail.application_number, "1020240001234")
self.assertTrue(calls[0][0].endswith("/getBibliographyDetailInfoSearch"))
self.assertEqual(calls[0][1]["applicationNumber"], "1020240001234")
def test_search_patents_surfaces_api_auth_errors_cleanly(self):
with self.assertRaisesRegex(RuntimeError, "SERVICE KEY IS NOT REGISTERED ERROR"):
search_patents(
"배터리",
service_key="bad-key",
fetcher=lambda url, params, timeout: SAMPLE_AUTH_ERROR_XML,
)
class CliTest(unittest.TestCase):
def test_parse_args_supports_query_and_application_number_modes(self):
args = parse_args(["--query", "배터리", "--year", "2024", "--num-rows", "5"])
self.assertEqual(args.query, "배터리")
self.assertEqual(args.year, 2024)
self.assertEqual(args.num_rows, 5)
detail_args = parse_args(["--application-number", "1020240001234"])
self.assertEqual(detail_args.application_number, "1020240001234")
def test_main_prints_query_report_as_json(self):
with mock.patch("scripts.patent_search.search_patents") as search_mock:
search_mock.return_value = PatentSearchResponse(
query="배터리",
page_no=1,
num_of_rows=1,
total_count=1,
items=[
PatentSearchResult(
index_no=1,
application_number="1020240001234",
invention_title="이차 전지 배터리 팩",
register_status="공개",
application_date="2024/01/02 00:00:00",
open_number="1020250005678",
open_date="2025/07/09 00:00:00",
publication_number="1020250005678",
publication_date="2025/07/09 00:00:00",
register_number=None,
register_date=None,
ipc_number="H01M 10/00",
abstract_text="배터리 수명 향상을 위한 열 관리 구조.",
applicant_name="주식회사 오픈에이아이코리아",
drawing="http://example.com/thumb.png",
big_drawing="http://example.com/big.png",
)
],
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = main(["--query", "배터리", "--service-key", "test-key"])
self.assertEqual(exit_code, 0)
self.assertIn('"query": "배터리"', stdout.getvalue())
def test_main_reports_missing_api_key(self):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
exit_code = main(["--query", "배터리"])
self.assertEqual(exit_code, 2)
self.assertIn("KIPRIS_PLUS_API_KEY", stderr.getvalue())

View file

@ -0,0 +1,127 @@
import contextlib
import io
import json
import os
import shlex
from datetime import date
from pathlib import Path
import unittest
from unittest import mock
from scripts.subway_lost_property import (
LOST112_LIST_URL,
SEOUL_METRO_LOST_CENTER_URL,
SearchQuery,
build_curl_command,
build_search_payload,
build_search_plan,
expand_station_keywords,
main,
probe_source,
)
class SubwayLostPropertyQueryTest(unittest.TestCase):
def test_build_search_payload_defaults_to_external_agency_search(self):
payload = build_search_payload(
SearchQuery(
station="강남역",
item="지갑",
start_date=date(2026, 4, 1),
end_date=date(2026, 4, 10),
)
)
self.assertEqual(payload["START_YMD"], "20260401")
self.assertEqual(payload["END_YMD"], "20260410")
self.assertEqual(payload["PRDT_NM"], "지갑")
self.assertEqual(payload["DEP_PLACE"], "강남역")
self.assertEqual(payload["SITE"], "V")
self.assertEqual(payload["pageIndex"], "1")
def test_expand_station_keywords_keeps_station_and_strips_suffix(self):
self.assertEqual(expand_station_keywords(" 강남역 "), ["강남역", "강남"])
def test_build_search_plan_serializes_official_sources_and_guidance(self):
plan = build_search_plan(
station="강남역",
item="지갑",
days=14,
today=date(2026, 4, 10),
)
self.assertEqual(plan.query.station, "강남역")
self.assertEqual(plan.query.item, "지갑")
self.assertEqual(plan.query.start_date.isoformat(), "2026-03-27")
self.assertEqual(plan.query.end_date.isoformat(), "2026-04-10")
self.assertEqual(plan.official_sources[0]["url"], LOST112_LIST_URL)
self.assertEqual(plan.official_sources[1]["url"], SEOUL_METRO_LOST_CENTER_URL)
self.assertIn("강남역", plan.suggested_keywords)
self.assertIn("강남", plan.suggested_keywords)
command = shlex.split(build_curl_command(plan.payload))
self.assertNotIn("-L", command)
self.assertIn("--max-time", command)
self.assertEqual(command[command.index("--max-time") + 1], "60")
self.assertIn("--referer", command)
self.assertEqual(command[command.index("--referer") + 1], "https://www.lost112.go.kr/")
self.assertIn("--output", command)
self.assertEqual(command[command.index("--output") + 1], "lost112-search-result.html")
self.assertIn("SITE=V", " ".join(command))
self.assertEqual(command[-1], LOST112_LIST_URL)
def test_blank_station_is_rejected(self):
with self.assertRaisesRegex(ValueError, "station"):
build_search_plan(station=" ")
class SubwayLostPropertyProbeTest(unittest.TestCase):
def test_probe_source_marks_successful_fetch_as_reachable(self):
runner = mock.Mock(return_value=mock.Mock(returncode=0, stdout="<html></html>", stderr=""))
status = probe_source("LOST112", LOST112_LIST_URL, runner=runner)
self.assertEqual(status["status"], "reachable")
command = runner.call_args.args[0]
self.assertEqual(command[0], "curl")
self.assertIn("--http1.1", command)
self.assertEqual(command[command.index("--tls-max") + 1], "1.2")
self.assertEqual(command[command.index("--max-time") + 1], "15")
self.assertEqual(command[-1], LOST112_LIST_URL)
def test_probe_source_marks_timeouts_cleanly(self):
runner = mock.Mock(side_effect=__import__("subprocess").CalledProcessError(28, ["curl"], stderr="Operation timed out"))
status = probe_source("서울교통공사", SEOUL_METRO_LOST_CENTER_URL, runner=runner)
self.assertEqual(status["status"], "timeout")
self.assertIn("timed out", status["detail"].lower())
class SubwayLostPropertyCliShapeTest(unittest.TestCase):
def test_cli_prints_json_plan(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
main(["--station", "강남역", "--item", "지갑", "--days", "14"])
payload = json.loads(stdout.getvalue())
self.assertEqual(payload["query"]["station"], "강남역")
self.assertEqual(payload["payload"]["SITE"], "V")
self.assertIn("curl", payload["curl_example"])
self.assertEqual(payload["official_sources"][0]["url"], LOST112_LIST_URL)
def test_helper_scripts_are_executable_python_entrypoints(self):
repo_root = Path(__file__).resolve().parent.parent
for helper in (
repo_root / "scripts" / "subway_lost_property.py",
repo_root / "subway-lost-property" / "scripts" / "subway_lost_property.py",
):
with self.subTest(helper=helper):
self.assertTrue(os.access(helper, os.X_OK), f"{helper} should be executable")
self.assertTrue(
helper.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n"),
f"{helper} should start with a Python shebang",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,102 @@
import json
import os
from pathlib import Path
import unittest
from unittest import mock
from scripts.zipcode_search import (
SEARCH_URL,
AddressSearchResult,
fetch_search_page,
lookup_korean_address,
parse_search_results,
)
SAMPLE_HTML = """
<table>
<tbody>
<tr class="title2">
<th scope="row">06133</th>
<td class="t_a_l l_h_18">
서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)<br />
서울특별시 강남구 역삼동 648-23 (여삼빌딩)
</td>
<td><a class="btn_s gray" href="#" onclick="javascript:viewDetail('06133','서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)','123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA','서울특별시 강남구 역삼동 648-23 (여삼빌딩)', '0');" title="보기">더보기</a></td>
</tr>
<tr class="view">
<td class="p_l_86px" colspan="3">
123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA
</td>
</tr>
</tbody>
</table>
"""
class ZipcodeSearchParsingTest(unittest.TestCase):
def test_parse_search_results_extracts_official_korean_and_english_addresses(self):
items = parse_search_results(SAMPLE_HTML)
self.assertEqual(
items,
[
AddressSearchResult(
zip_code="06133",
road_address="서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
english_address="123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
jibun_address="서울특별시 강남구 역삼동 648-23 (여삼빌딩)",
)
],
)
def test_lookup_korean_address_rejects_blank_query(self):
with self.assertRaisesRegex(ValueError, "query"):
lookup_korean_address(" ")
class ZipcodeSearchTransportTest(unittest.TestCase):
def test_fetch_search_page_uses_official_https_endpoint_and_curl_safety_flags(self):
runner = mock.Mock(return_value=mock.Mock(stdout="<html></html>"))
page = fetch_search_page("서울특별시 강남구 테헤란로 123", runner=runner)
self.assertEqual(page, "<html></html>")
command = runner.call_args.args[0]
self.assertEqual(command[0], "curl")
self.assertIn("--http1.1", command)
self.assertEqual(command[command.index("--tls-max") + 1], "1.2")
self.assertEqual(command[command.index("--retry") + 1], "3")
self.assertIn("--retry-all-errors", command)
self.assertEqual(command[command.index("--retry-delay") + 1], "1")
self.assertEqual(command[command.index("--max-time") + 1], "20")
self.assertEqual(command[-1], SEARCH_URL)
class ZipcodeSearchCliShapeTest(unittest.TestCase):
def test_lookup_response_is_json_serializable(self):
response = lookup_korean_address(
"서울특별시 강남구 테헤란로 123",
fetcher=lambda _query: SAMPLE_HTML,
)
payload = json.loads(response.to_json())
self.assertEqual(payload["query"], "서울특별시 강남구 테헤란로 123")
self.assertEqual(payload["results"][0]["zip_code"], "06133")
self.assertIn("Teheran-ro", payload["results"][0]["english_address"])
def test_helper_scripts_are_executable_python_entrypoints(self):
repo_root = Path(__file__).resolve().parent.parent
for helper in (
repo_root / "scripts" / "zipcode_search.py",
repo_root / "zipcode-search" / "scripts" / "zipcode_search.py",
):
with self.subTest(helper=helper):
self.assertTrue(os.access(helper, os.X_OK), f"{helper} should be executable")
self.assertTrue(
helper.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n"),
f"{helper} should start with a Python shebang",
)
if __name__ == "__main__":
unittest.main()

View file

@ -40,6 +40,7 @@ done < <(
! -name .github \
! -name .claude \
! -name .omx \
! -name .ouroboros \
! -name .changeset \
! -name docs \
! -name node_modules \

13
scripts/zipcode_search.py Executable file
View file

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

View file

@ -0,0 +1,103 @@
---
name: subway-lost-property
description: Use when the user asks how to look up 지하철 분실물 or 유실물 by 역명/물품명. v1 is an 안내형/하이브리드 skill that structures the official LOST112 + 서울교통공사 flow conservatively.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Subway Lost Property
## What this skill does
지하철에서 잃어버린 물건을 찾기 위해 공식 경로를 구조화한다.
- LOST112 `습득물 목록 조회`에 넣을 검색 조건을 정리한다.
- 서울교통공사 유실물센터 진입점을 함께 안내한다.
- 공개 API가 명확하지 않은 상태라 v1은 **안내형/하이브리드** 범위로 유지한다.
## When to use
- "강남역에서 지갑 잃어버렸는데 어디서 찾아?"
- "2호선 지하철 분실물 조회 방법 알려줘"
- "서울 지하철 유실물 공식 사이트로 바로 찾게 도와줘"
## Inputs
- 필수: 역명 또는 보관장소 키워드
- 선택: 물품명, 호선, 분실/습득 추정 기간
## Official surfaces
- LOST112 습득물 목록: `https://www.lost112.go.kr/find/findList.do`
- 서울교통공사 유실물센터: `https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541`
LOST112 검색 폼에서 확인되는 핵심 필드는 아래와 같다.
- `SITE=V`: 경찰 이외 기관(지하철, 공항 등)
- `DEP_PLACE`: 보관장소
- `PRDT_NM`: 습득물명
- `START_YMD`, `END_YMD`: 검색 기간
## Workflow
### 1) Ask for the minimum clues first
바로 추정하지 말고 아래 정보를 먼저 받는다.
- 어느 역/어느 구간인지
- 물건 종류가 무엇인지
- 대략 언제 잃어버렸는지
- 서울교통공사(1~8호선) 범위인지, 다른 운영사인지
### 2) Generate the official LOST112 search payload
repo helper를 그대로 써도 된다.
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14
```
helper는 기본적으로 `SITE=V` 를 사용하고, 역명/물품명/기간을 LOST112 form payload와 **referer까지 포함한 runnable `curl` 예시**로 정리해 준다. 예시 `curl` 은 느린 공식 응답을 감안해 `--max-time 60` 을 포함하고, 응답 HTML을 `lost112-search-result.html` 로 저장한다.
### 3) Optionally verify live reachability
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14 \
--verify-live
```
`--verify-live` 는 공식 페이지 접근 가능 여부만 보수적으로 확인한다. 사이트가 느리면 timeout을 그대로 보고하고 manual open으로 전환한다.
### 4) Guide the user conservatively
- 먼저 LOST112에서 역명 그대로 검색
- 결과가 없으면 `강남`처럼 `역` 없는 키워드로 재검색
- 필요하면 호선명도 추가 검색
- 서울교통공사 유실물센터 페이지를 함께 열어 후속 안내 확인
## Done when
- 사용자가 공식 조회 경로를 바로 열 수 있다.
- LOST112 검색 조건(`SITE=V`, 역명, 물품명, 기간)을 받았다.
- 자동 조회 보장 범위와 manual fallback을 분명히 설명했다.
## Failure modes
- 공식 사이트 응답이 느리거나 timeout 발생
- 역명이 실제 보관장소 표기와 달라 검색 결과가 비는 경우
- 공개 API 부재로 자동 결과 수집이 안정적이지 않은 경우
## Notes
- v1은 공식 웹 흐름을 안전하게 안내하는 범위다.
- 완전 자동 조회형으로 확장하려면 캡차/세션/동적 요청 안정성 재검증이 먼저 필요하다.
- helper는 공식 HTTPS 진입점만 사용한다.

View file

@ -0,0 +1,244 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import shlex
import subprocess
from dataclasses import asdict, dataclass
from datetime import date, timedelta
from typing import Callable
LOST112_LIST_URL = "https://www.lost112.go.kr/find/findList.do"
LOST112_REFERER_URL = "https://www.lost112.go.kr/"
LOST112_OUTPUT_FILE = "lost112-search-result.html"
LOST112_CURL_MAX_TIME = 60
SEOUL_METRO_LOST_CENTER_URL = "https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541"
CURL_USER_AGENT = "Mozilla/5.0"
Runner = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True)
class SearchQuery:
station: str
item: str | None = None
line: str | None = None
start_date: date | None = None
end_date: date | None = None
@dataclass(frozen=True)
class SearchPlan:
query: SearchQuery
payload: dict[str, str]
suggested_keywords: list[str]
official_sources: list[dict[str, str]]
guidance: list[str]
cautions: list[str]
curl_example: str
def to_dict(self) -> dict[str, object]:
return {
"query": {
**asdict(self.query),
"start_date": self.query.start_date.isoformat() if self.query.start_date else None,
"end_date": self.query.end_date.isoformat() if self.query.end_date else None,
},
"payload": self.payload,
"suggested_keywords": self.suggested_keywords,
"official_sources": self.official_sources,
"guidance": self.guidance,
"cautions": self.cautions,
"curl_example": self.curl_example,
}
def normalize_station(station: str) -> str:
normalized = " ".join(station.split())
if not normalized:
raise ValueError("station is required")
return normalized
def expand_station_keywords(station: str) -> list[str]:
normalized = normalize_station(station)
keywords = [normalized]
if normalized.endswith("") and len(normalized) > 1:
keywords.append(normalized[:-1])
return list(dict.fromkeys(keyword for keyword in keywords if keyword))
def build_search_payload(query: SearchQuery) -> dict[str, str]:
station = normalize_station(query.station)
if not query.start_date or not query.end_date:
raise ValueError("start_date and end_date are required")
payload = {
"pageIndex": "1",
"START_YMD": query.start_date.strftime("%Y%m%d"),
"END_YMD": query.end_date.strftime("%Y%m%d"),
"PRDT_NM": (query.item or "").strip(),
"DEP_PLACE": station,
"SITE": "V",
"PLACE_SE_CD": "",
"FD_LCT_CD": "",
"FD_SIGUNGU": "",
"IN_NM": "",
"ATC_ID": "",
"F_ATC_ID": "",
"PRDT_CL_CD01": "",
"PRDT_CL_CD02": "",
"PRDT_CL_NM": "",
"MENU_NO": "",
}
return payload
def _base_curl_command(url: str | None, max_time: int, *, follow_redirects: bool = True) -> list[str]:
command = [
"curl",
"-fsS",
"--http1.1",
"--tls-max",
"1.2",
"--retry",
"1",
"--max-time",
str(max_time),
"-A",
CURL_USER_AGENT,
]
if follow_redirects:
command.insert(2, "-L")
if url:
command.append(url)
return command
def build_curl_command(payload: dict[str, str]) -> str:
command = _base_curl_command("", LOST112_CURL_MAX_TIME, follow_redirects=False)
command.extend(["--referer", LOST112_REFERER_URL])
for key, value in payload.items():
if value:
command.extend(["--data-urlencode", f"{key}={value}"])
command.extend(["--output", LOST112_OUTPUT_FILE])
command.append(LOST112_LIST_URL)
return " ".join(shlex.quote(part) for part in command)
def probe_source(name: str, url: str, runner: Runner = subprocess.run) -> dict[str, str]:
command = _base_curl_command(url, 15)
try:
completed = runner(command, capture_output=True, text=True, check=True)
return {
"name": name,
"url": url,
"status": "reachable",
"detail": f"fetched {len(completed.stdout)} bytes",
}
except subprocess.CalledProcessError as error:
detail = (error.stderr or error.stdout or str(error)).strip() or "unknown error"
status = "timeout" if error.returncode == 28 or "timed out" in detail.lower() else "error"
return {"name": name, "url": url, "status": status, "detail": detail}
def build_search_plan(
station: str,
item: str | None = None,
line: str | None = None,
days: int = 30,
today: date | None = None,
verify_live: bool = False,
probe: Callable[[str, str], dict[str, str]] | None = None,
) -> SearchPlan:
normalized_station = normalize_station(station)
if days <= 0:
raise ValueError("days must be positive")
today = today or date.today()
end_date = today
start_date = today - timedelta(days=days)
query = SearchQuery(
station=normalized_station,
item=(item or "").strip() or None,
line=(line or "").strip() or None,
start_date=start_date,
end_date=end_date,
)
payload = build_search_payload(query)
suggested_keywords = expand_station_keywords(normalized_station)
if query.line:
suggested_keywords.append(query.line)
suggested_keywords = list(dict.fromkeys(suggested_keywords))
default_sources = [
{
"name": "LOST112 습득물 목록",
"url": LOST112_LIST_URL,
"purpose": "경찰 이외 기관(지하철·공항 등) 습득물 검색",
"status": "not_checked",
},
{
"name": "서울교통공사 유실물센터",
"url": SEOUL_METRO_LOST_CENTER_URL,
"purpose": "서울 지하철 공식 유실물 진입점/추가 안내",
"status": "not_checked",
},
]
if verify_live:
probe = probe or probe_source
default_sources = [
{**probe(source["name"], source["url"]), "purpose": source["purpose"]}
for source in default_sources
]
item_hint = query.item or "분실물"
guidance = [
f"먼저 LOST112에서 보관장소를 '{normalized_station}' 로, 물품명은 '{item_hint}' 로 검색합니다.",
"검색 폼에서는 SITE=V(경찰이외의기관) 기준으로 지하철/공항 등 기관 습득물을 우선 좁힙니다.",
"결과가 없으면 역명에서 ''을 뺀 키워드나 호선명을 추가로 검색합니다.",
"서울교통공사 안내 페이지를 함께 열어 운영사 유실물센터/후속 절차를 확인합니다.",
]
cautions = [
"v1은 공식 웹 조회 경로를 구조화하는 안내형/하이브리드 스킬이다.",
"공개 API가 확인되지 않아 자동 결과 수집은 보장하지 않는다.",
"공식 사이트 응답 속도가 느리면 manual open으로 전환한다.",
]
return SearchPlan(
query=query,
payload=payload,
suggested_keywords=suggested_keywords,
official_sources=default_sources,
guidance=guidance,
cautions=cautions,
curl_example=build_curl_command(payload),
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate official subway lost-property search guidance.")
parser.add_argument("--station", required=True, help="역명 또는 보관장소 키워드")
parser.add_argument("--item", help="예: 지갑, 이어폰")
parser.add_argument("--line", help="예: 2호선")
parser.add_argument("--days", type=int, default=30, help="검색 기간(일), 기본값 30")
parser.add_argument("--verify-live", action="store_true", help="공식 페이지 reachability를 curl로 확인")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
plan = build_search_plan(
station=args.station,
item=args.item,
line=args.line,
days=args.days,
verify_live=args.verify_live,
)
print(json.dumps(plan.to_dict(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View file

@ -1,30 +1,30 @@
---
name: zipcode-search
description: Look up a Korean postcode from a known address with the official ePost road-name search page. Use when the user knows the address but wants the postal code quickly.
description: Look up a Korean postcode and official English address from a known address with the official ePost integrated search page.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
phase: v2
---
# Zipcode Search
## What this skill does
우체국 공식 도로명주소 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호를 빠르게 찾는다.
우체국 공식 통합 우편번호 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호와 공식 영문 주소를 함께 찾는다.
## When to use
- "이 주소 우편번호 뭐야"
- "세종대로 209 우편번호 찾아줘"
- "판교역로 235 주소 코드만 빨리 알려줘"
- "이 주소 우편번호랑 영문 주소 같이 알려줘"
- "서울특별시 강남구 테헤란로 123 영문 주소로 바꿔줘"
- "해외 결제용으로 한국 주소 영문 표기 필요해"
## Prerequisites
- 인터넷 연결
- `curl`
- 선택 사항: `python3`
- `python3`
## Inputs
@ -35,19 +35,19 @@ metadata:
## Workflow
### 1. Query the official ePost page first
### 1. Query the official integrated ePost page first
비공식 지도 검색이나 블로그 주소 데이터로 우회하지 말고 아래 우체국 공식 검색 페이지를 먼저 조회한다.
비공식 영문주소 변환기나 블로그 표기를 쓰지 말고 아래 우체국 공식 통합 검색 페이지를 먼저 조회한다.
```text
https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
```
요청은 `keyword` 파라미터 하나만으로도 동작한다.
이 페이지는 `keyword` 파라미터로 우편번호, 국문 주소, `English/집배코드` 열의 공식 영문 주소를 함께 돌려준다.
### 2. Fetch the HTML with curl and extract the candidate rows
### 2. Fetch the HTML with curl and extract the `viewDetail(...)` rows
현재 ePost 엔드포인트는 응답이 간헐적으로 reset/timeout 될 수 있으므로, 로컬 `python3` 기본 `urllib` 전송 대신 `curl --http1.1 --tls-max 1.2` + 재시도 경로를 기본 예시로 사용한다.
현재 ePost 엔드포인트는 응답이 간헐적으로 reset/timeout 될 수 있으므로, 로컬 `urllib` 대신 `curl --http1.1 --tls-max 1.2` + 재시도 경로를 기본 예시로 사용한다.
```bash
python3 - <<'PY'
@ -55,7 +55,7 @@ import html
import re
import subprocess
query = "세종대로 209"
query = "서울특별시 강남구 테헤란로 123"
cmd = [
"curl",
"--http1.1",
@ -74,71 +74,96 @@ cmd = [
"--get",
"--data-urlencode",
f"keyword={query}",
"https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp",
"https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm",
]
result = subprocess.run(
page = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
page = result.stdout
).stdout
matches = re.findall(
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
page,
re.S,
)
if not matches:
raise SystemExit("검색 결과가 없습니다.")
for zip_code, address, building in matches[:5]:
suffix = f" ({building})" if building else ""
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
for zip_code, road_address, english_address, jibun_address in matches[:5]:
print(zip_code)
print(html.unescape(road_address))
print(html.unescape(english_address))
print(html.unescape(jibun_address))
print("---")
PY
```
핵심 필드는 `sch_zipcode`(우편번호), `sch_address1`(기본 주소), `sch_bdNm`(건물명)이다.
핵심 값은 `viewDetail(zip, roadAddress, englishAddress, jibunAddress, rowIndex)` 인자다. 공식 출력은 보통 `123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA` 같은 형식을 그대로 준다.
바깥쪽 Python `timeout`은 두지 말고 `curl` 자체 제한(`--max-time` + `--retry`)으로 전송 시간을 제어한다. 전송 실패가 나도 바로 다른 소스로 우회하지 말고, 위 재시도 옵션 그대로 한 번 더 실행한 뒤 키워드를 더 구체화한다. 실전에서는 `세종대로 209` 같은 짧은 도로명 + 건물번호를 먼저 넣고, 실패하면 `서울 종로구 세종대로 209` 같은 시/군/구 포함 전체 주소 순으로 재시도한다.
### 3. Prefer the shipped helper for repeatable execution
### 3. Normalize for humans
저장소에는 같은 흐름을 감싼 실행 가능한 helper가 포함되어 있다.
```bash
python3 scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
./scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
```
예시 출력:
```json
{
"query": "서울특별시 강남구 테헤란로 123",
"results": [
{
"zip_code": "06133",
"road_address": "서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
"english_address": "123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
"jibun_address": "서울특별시 강남구 역삼동 648-23 (여삼빌딩)"
}
]
}
```
### 4. Normalize for humans
응답은 raw HTML이므로 그대로 붙이지 말고 아래처럼 정리한다.
- 우편번호
- 표준 주소
- 건물명이 있으면 함께 표기
- 도로명 국문 주소
- 공식 영문 주소
- 필요하면 지번 주소
- 후보가 여러 개면 상위 3~5개만 보여주고 어느 항목이 가장 근접한지 짚기
### 4. Retry with tighter and fuller keywords when needed
### 5. Retry with tighter and fuller keywords when needed
검색 결과가 없거나 timeout/reset이 반복되면 아래 순서로 재시도한다.
- 짧은 도로명 + 건물번호: `세종대로 209`
- 시/군/구 포함 전체 주소: `서울 종로구 세종대로 209`
- 동/리 + 지번 또는 대체 표기: `세종로 209`
- 짧은 도로명 + 건물번호: `테헤란로 123`
- 시/군/구 포함 전체 주소: `서울 강남구 테헤란로 123`
- 동/리 + 지번 또는 대체 표기: `역삼동 648-23`
### 5. Prefer temp files in wrapped shells
### 6. Prefer temp files in wrapped shells
CLI 래퍼나 에이전트 쉘에서는 here-doc + Python one-liner가 깨질 수 있으므로, 실전에서는 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 그 파일을 파싱하는 경로를 우선한다. 응답 일부만 보려고 `| head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있으니, 이 경우도 전체 응답을 임시 파일에 저장한 뒤 확인한다.
## Done when
- 적어도 한 개의 우편번호 후보가 정리되어 있다
- 다중 후보일 때 사용자가 고를 수 있게 주소 차이가 보인다
- 적어도 한 개의 우편번호 후보와 공식 영문 주소가 정리되어 있다
- 다중 후보일 때 사용자가 고를 수 있게 국문/영문 주소 차이가 보인다
- 검색 결과가 없으면 재검색 키워드 방향을 제안했다
## Failure modes
- 우체국 검색 페이지 마크업이 바뀌면 `sch_zipcode` 추출 규칙이 깨질 수 있다
- 우체국 검색 페이지 마크업이 바뀌면 `viewDetail(...)` 추출 규칙이 깨질 수 있다
- 주소 키워드가 너무 넓으면 결과가 과하게 많아질 수 있다
- 재시도 없이 한 번만 호출하면 timeout/reset 같은 일시 오류가 날 수 있다
- `curl` 없이 기본 `urllib` 전송으로 바로 붙으면 연결 reset이 날 수 있다
- `curl` 없이 다른 클라이언트로 바로 붙으면 협상/전송 오류가 날 수 있다
## Notes
- 조회형 스킬이다
- 공식 표기 그대로 유지하는 조회형 스킬이다
- 상대 날짜/실시간 개념은 없으므로 주소 문자열 정제에 집중한다

View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import html
import json
import subprocess
from dataclasses import asdict, dataclass
import re
from typing import Callable, Sequence
SEARCH_URL = "https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm"
DEFAULT_LIMIT = 5
VIEW_DETAIL_PATTERN = re.compile(
r"viewDetail\(\s*'(?P<zip>(?:\\'|[^'])*)'\s*,\s*'(?P<road>(?:\\'|[^'])*)'\s*,\s*'(?P<english>(?:\\'|[^'])*)'\s*,\s*'(?P<jibun>(?:\\'|[^'])*)'\s*,\s*'(?P<row>(?:\\'|[^'])*)'\s*\)",
re.S,
)
@dataclass(frozen=True)
class AddressSearchResult:
zip_code: str
road_address: str
english_address: str
jibun_address: str | None = None
@dataclass(frozen=True)
class AddressSearchResponse:
query: str
results: list[AddressSearchResult]
def to_json(self) -> str:
return json.dumps(
{
"query": self.query,
"results": [asdict(item) for item in self.results],
},
ensure_ascii=False,
indent=2,
)
def clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = html.unescape(value).replace("\\'", "'")
cleaned = " ".join(cleaned.split()).strip()
return cleaned or None
def parse_search_results(page: str) -> list[AddressSearchResult]:
items: list[AddressSearchResult] = []
for match in VIEW_DETAIL_PATTERN.finditer(page):
zip_code = clean_text(match.group("zip"))
road_address = clean_text(match.group("road"))
english_address = clean_text(match.group("english"))
jibun_address = clean_text(match.group("jibun"))
if not zip_code or not road_address or not english_address:
continue
items.append(
AddressSearchResult(
zip_code=zip_code,
road_address=road_address,
english_address=english_address,
jibun_address=jibun_address,
)
)
return items
def build_search_command(query: str) -> list[str]:
return [
"curl",
"--http1.1",
"--tls-max",
"1.2",
"--silent",
"--show-error",
"--location",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"1",
"--max-time",
"20",
"--get",
"--data-urlencode",
f"keyword={query}",
SEARCH_URL,
]
Runner = Callable[..., subprocess.CompletedProcess[str]]
def fetch_search_page(query: str, *, runner: Runner = subprocess.run) -> str:
result = runner(
build_search_command(query),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return result.stdout
Fetcher = Callable[[str], str]
def lookup_korean_address(
query: str,
*,
limit: int = DEFAULT_LIMIT,
fetcher: Fetcher = fetch_search_page,
) -> AddressSearchResponse:
normalized_query = " ".join(query.split()).strip()
if not normalized_query:
raise ValueError("query must not be blank")
if limit <= 0:
raise ValueError("limit must be a positive integer")
page = fetcher(normalized_query)
return AddressSearchResponse(
query=normalized_query,
results=parse_search_results(page)[:limit],
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Look up Korean postcodes and official English addresses from ePost.",
)
parser.add_argument("query", help="Korean road-name or jibun address query")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="maximum number of rows to keep")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
response = lookup_korean_address(args.query, limit=args.limit)
print(response.to_json())
return 0
if __name__ == "__main__":
raise SystemExit(main())