mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge branch 'dev' into feature/k-schoollunch-menu
This commit is contained in:
commit
1e89bace8b
81 changed files with 9733 additions and 145 deletions
5
.changeset/bright-apricots-prove.md
Normal file
5
.changeset/bright-apricots-prove.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"lck-analytics": minor
|
||||
---
|
||||
|
||||
Add the first LCK analytics package and skill pack adapted from jerjangmin's original upstream implementation.
|
||||
5
.changeset/cheap-gas-nearby-skill.md
Normal file
5
.changeset/cheap-gas-nearby-skill.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"cheap-gas-nearby": minor
|
||||
---
|
||||
|
||||
Publish the first official Opinet-powered nearby cheapest gas station lookup package and skill docs.
|
||||
5
.changeset/hipass-receipt-skill.md
Normal file
5
.changeset/hipass-receipt-skill.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"hipass-receipt": minor
|
||||
---
|
||||
|
||||
Publish the first logged-in-session helper package and skill docs for Hi-Pass receipt workflows.
|
||||
5
.changeset/issue-63-blue-ribbon-premium-required.md
Normal file
5
.changeset/issue-63-blue-ribbon-premium-required.md
Normal 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.
|
||||
5
.changeset/market-kurly-search-skill.md
Normal file
5
.changeset/market-kurly-search-skill.md
Normal 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.
|
||||
5
.changeset/used-car-price-search-skill.md
Normal file
5
.changeset/used-car-price-search-skill.md
Normal 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.
|
||||
31
README.md
31
README.md
|
|
@ -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
164
bunjang-search/SKILL.md
Normal 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 중 필요한 경로가 안내되었다.
|
||||
- 찜/채팅은 로그인 필요성과 선택적 성격이 명확히 고지되었다.
|
||||
- 자동 구매/결제는 범위 밖이라고 분명히 말했다.
|
||||
152
docs/features/bunjang-search.md
Normal file
152
docs/features/bunjang-search.md
Normal 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`
|
||||
108
docs/features/hipass-receipt.md
Normal file
108
docs/features/hipass-receipt.md
Normal 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 문서에 추가하지 않는다.
|
||||
- 로그인은 반드시 사용자가 브라우저 안에서 직접 수행한다.
|
||||
- 이 기능은 조회/영수증 보조까지만 다루며 장기 무인 자동화는 약속하지 않는다.
|
||||
|
|
@ -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 로 검증합니다.
|
||||
|
|
|
|||
70
docs/features/korea-weather.md
Normal file
70
docs/features/korea-weather.md
Normal 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)를 본다.
|
||||
120
docs/features/korean-character-count.md
Normal file
120
docs/features/korean-character-count.md
Normal 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
|
||||
106
docs/features/korean-patent-search.md
Normal file
106
docs/features/korean-patent-search.md
Normal 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` 가 준비된 환경에서는 바로 실검색으로 이어서 검증할 수 있다.
|
||||
81
docs/features/korean-stock-search.md
Normal file
81
docs/features/korean-stock-search.md
Normal 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`
|
||||
72
docs/features/market-kurly-search.md
Normal file
72
docs/features/market-kurly-search.md
Normal 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 기준으로는 마켓컬리 상품 검색과 가격 조회를 로그인 없이 구현할 수 있음** 을 다시 검증했습니다. 다만 이 표면은 웹 내부 사용 경로이므로 이후 스키마/헤더 요구사항이 바뀌면 수정이 필요할 수 있습니다.
|
||||
87
docs/features/mfds-drug-safety.md
Normal file
87
docs/features/mfds-drug-safety.md
Normal 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 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.
|
||||
92
docs/features/mfds-food-safety.md
Normal file
92
docs/features/mfds-food-safety.md
Normal 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 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.
|
||||
76
docs/features/subway-lost-property.md
Normal file
76
docs/features/subway-lost-property.md
Normal 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가 확인되기 전까지는 완전 자동 조회형으로 취급하지 않는다.
|
||||
|
|
@ -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(...)` 추출을 붙입니다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
|
|
|
|||
105
docs/install.md
105
docs/install.md
|
|
@ -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`가 배포된 경우 사용자 시크릿 불필요)
|
||||
|
|
|
|||
|
|
@ -10,23 +10,32 @@
|
|||
- K리그 경기 결과 조회 스킬 출시
|
||||
- LCK 경기 분석 스킬 출시
|
||||
- 토스증권 조회 스킬 출시
|
||||
- 하이패스 영수증 발급 스킬 출시
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 한국 날씨 조회 스킬 출시
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 한강 수위 정보 조회 스킬 출시
|
||||
- 한국 법령 검색 스킬 출시
|
||||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
- 마켓컬리 상품 조회 스킬 출시
|
||||
- 올리브영 검색 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 번개장터 검색 스킬 출시
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
- 한국어 맞춤법 검사 스킬 출시
|
||||
- 한국어 글자 수 세기 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
@ -109,10 +118,10 @@
|
|||
- 장점: 결제/포인트/배송/가격비교를 묶는 쇼핑형 허브 후보가 된다
|
||||
- 이유: 스마트스토어와 별도의 큰 축으로 키울 수 있다
|
||||
|
||||
#### 한국 기상청 날씨/특보
|
||||
#### 한국 기상청 특보/중기예보 확장
|
||||
|
||||
- 장점: 국내 날씨, 특보, 단기 예보를 한국형 생활 정보로 직접 연결하기 좋다
|
||||
- 이유: 미세먼지 스킬과 함께 묶었을 때 생활 밀착도가 더 올라간다
|
||||
- 장점: 이미 선출시한 한국 날씨 조회 스킬에 특보/중기예보를 붙여 생활 정보 깊이를 늘릴 수 있다
|
||||
- 이유: 단기예보 다음 단계로 자연스럽게 확장 가능하다
|
||||
|
||||
### 기존 탐색 후보
|
||||
|
||||
|
|
|
|||
|
|
@ -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)를 본다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
107
hipass-receipt/SKILL.md
Normal 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를 선택해 영수증 팝업 진입을 시도할 수 있다.
|
||||
- 세션 종료 응답을 감지하면 재로그인을 요구한다.
|
||||
|
|
@ -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
101
korea-weather/SKILL.md
Normal 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` 를 참고한다.
|
||||
97
korean-character-count/SKILL.md
Normal file
97
korean-character-count/SKILL.md
Normal 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
|
||||
268
korean-character-count/scripts/korean_character_count.js
Normal file
268
korean-character-count/scripts/korean_character_count.js
Normal 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,
|
||||
};
|
||||
89
korean-patent-search/SKILL.md
Normal file
89
korean-patent-search/SKILL.md
Normal 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에 집중한다.
|
||||
- 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 별도 심의 대상이다.
|
||||
409
korean-patent-search/scripts/patent_search.py
Normal file
409
korean-patent-search/scripts/patent_search.py
Normal 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())
|
||||
202
korean-stock-search/SKILL.md
Normal file
202
korean-stock-search/SKILL.md
Normal 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 조회 전용이다.
|
||||
135
market-kurly-search/SKILL.md
Normal file
135
market-kurly-search/SKILL.md
Normal 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
90
mfds-drug-safety/SKILL.md
Normal 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으로 정리했다.
|
||||
- 최소한 제품명, 업체명, 효능/주의/상호작용이 포함된 요약을 제공했다.
|
||||
204
mfds-drug-safety/scripts/mfds_drug_safety.py
Normal file
204
mfds-drug-safety/scripts/mfds_drug_safety.py
Normal 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
98
mfds-food-safety/SKILL.md
Normal 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건 이상 찾았거나, 없다고 분명히 알렸다.
|
||||
- 제품명, 업체명, 공개사유/부적합 사유, 공개일자를 포함한 요약을 제공했다.
|
||||
279
mfds-food-safety/scripts/mfds_food_safety.py
Normal file
279
mfds-food-safety/scripts/mfds_food_safety.py
Normal 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
47
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
80
packages/hipass-receipt/README.md
Normal file
80
packages/hipass-receipt/README.md
Normal 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** 가 필요합니다.
|
||||
39
packages/hipass-receipt/package.json
Normal file
39
packages/hipass-receipt/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
229
packages/hipass-receipt/src/browser.js
Normal file
229
packages/hipass-receipt/src/browser.js
Normal 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
|
||||
}
|
||||
126
packages/hipass-receipt/src/cli.js
Executable file
126
packages/hipass-receipt/src/cli.js
Executable 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
|
||||
})
|
||||
43
packages/hipass-receipt/src/index.js
Normal file
43
packages/hipass-receipt/src/index.js
Normal 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,
|
||||
};
|
||||
451
packages/hipass-receipt/src/parse.js
Normal file
451
packages/hipass-receipt/src/parse.js
Normal 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(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/"/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,
|
||||
};
|
||||
58
packages/hipass-receipt/src/util.js
Normal file
58
packages/hipass-receipt/src/util.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
function decodeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/ | /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/"/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
|
||||
}
|
||||
23
packages/hipass-receipt/test/fixtures/login-page.html
vendored
Normal file
23
packages/hipass-receipt/test/fixtures/login-page.html
vendored
Normal 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>
|
||||
14
packages/hipass-receipt/test/fixtures/permission-check.html
vendored
Normal file
14
packages/hipass-receipt/test/fixtures/permission-check.html
vendored
Normal 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>
|
||||
14
packages/hipass-receipt/test/fixtures/session-expired.html
vendored
Normal file
14
packages/hipass-receipt/test/fixtures/session-expired.html
vendored
Normal 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>
|
||||
85
packages/hipass-receipt/test/fixtures/usage-history-list.html
vendored
Normal file
85
packages/hipass-receipt/test/fixtures/usage-history-list.html
vendored
Normal 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>
|
||||
459
packages/hipass-receipt/test/index.test.js
Normal file
459
packages/hipass-receipt/test/index.test.js
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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 실행
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
300
packages/k-skill-proxy/src/krx-stock.js
Normal file
300
packages/k-skill-proxy/src/krx-stock.js
Normal 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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
79
packages/market-kurly-search/README.md
Normal file
79
packages/market-kurly-search/README.md
Normal 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": [
|
||||
"샛별배송(내일 아침)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
32
packages/market-kurly-search/package.json
Normal file
32
packages/market-kurly-search/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
93
packages/market-kurly-search/src/index.js
Normal file
93
packages/market-kurly-search/src/index.js
Normal 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
|
||||
}
|
||||
224
packages/market-kurly-search/src/parse.js
Normal file
224
packages/market-kurly-search/src/parse.js
Normal 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
|
||||
}
|
||||
224
packages/market-kurly-search/test/index.test.js
Normal file
224
packages/market-kurly-search/test/index.test.js
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
14
scripts/korean_character_count.js
Normal file
14
scripts/korean_character_count.js
Normal 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;
|
||||
13
scripts/mfds_drug_safety.py
Normal file
13
scripts/mfds_drug_safety.py
Normal 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())
|
||||
13
scripts/mfds_food_safety.py
Normal file
13
scripts/mfds_food_safety.py
Normal 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
13
scripts/patent_search.py
Normal 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())
|
||||
|
|
@ -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
18
scripts/subway_lost_property.py
Executable 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())
|
||||
112
scripts/test_korean_character_count.js
Normal file
112
scripts/test_korean_character_count.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
79
scripts/test_mfds_drug_safety.py
Normal file
79
scripts/test_mfds_drug_safety.py
Normal 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()
|
||||
120
scripts/test_mfds_food_safety.py
Normal file
120
scripts/test_mfds_food_safety.py
Normal 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()
|
||||
359
scripts/test_patent_search.py
Normal file
359
scripts/test_patent_search.py
Normal 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())
|
||||
127
scripts/test_subway_lost_property.py
Normal file
127
scripts/test_subway_lost_property.py
Normal 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()
|
||||
102
scripts/test_zipcode_search.py
Normal file
102
scripts/test_zipcode_search.py
Normal 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()
|
||||
|
|
@ -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
13
scripts/zipcode_search.py
Executable 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())
|
||||
103
subway-lost-property/SKILL.md
Normal file
103
subway-lost-property/SKILL.md
Normal 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 진입점만 사용한다.
|
||||
244
subway-lost-property/scripts/subway_lost_property.py
Executable file
244
subway-lost-property/scripts/subway_lost_property.py
Executable 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()
|
||||
|
|
@ -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
|
||||
|
||||
- 조회형 스킬이다
|
||||
- 공식 표기 그대로 유지하는 조회형 스킬이다
|
||||
- 상대 날짜/실시간 개념은 없으므로 주소 문자열 정제에 집중한다
|
||||
|
|
|
|||
150
zipcode-search/scripts/zipcode_search.py
Executable file
150
zipcode-search/scripts/zipcode_search.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue