mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Keep PR #103 mergeable with the current dev branch
Merged origin/dev into feature/#0 and resolved the stale conflict surfaces by
matching the current dev tree. This preserves the branch history while making
the branch content equivalent to the already-verified integration branch so the
open PR no longer carries outdated merge blockers.
Constraint: The approved household-waste and school-lunch work is already integrated on dev
Rejected: Force-reset feature/#0 to origin/dev | would drop branch history and require a force push
Rejected: Preserve stale branch-only doc/test drift | kept the PR conflicting without new product value
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If this PR stays open for coordination only, prefer merge-sync commits over force-push rewrites
Tested: Tree equality against origin/dev after merge resolution
Tested: origin/dev npm run ci at commit 7b82d4d
Not-tested: Separate full CI rerun on feature/#0 after the tree-equivalent merge commit
This commit is contained in:
commit
f551ab208e
106 changed files with 12110 additions and 507 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.
|
||||
35
README.md
35
README.md
|
|
@ -24,30 +24,49 @@ 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) |
|
||||
| 긱뉴스 조회 | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.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) |
|
||||
| 네이버 블로그 리서치 | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.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 +94,41 @@ 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/geeknews-search.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/naver-blog-research.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`
|
||||
68
docs/features/geeknews-search.md
Normal file
68
docs/features/geeknews-search.md
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# 긱뉴스 조회 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- GeekNews 공개 RSS/Atom 피드에서 최신 글 목록 조회
|
||||
- 제목/요약/작성자 기준 키워드 검색
|
||||
- 특정 항목의 RSS 기반 요약/링크/작성자/게시 시각 확인
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 완료
|
||||
- `python3` 사용 가능 환경
|
||||
- 인터넷 연결
|
||||
|
||||
## v1 범위
|
||||
|
||||
이 기능은 **RSS-first / 읽기 전용** 범위로 제공된다.
|
||||
|
||||
- 공개 피드(`https://feeds.feedburner.com/geeknews-feed`)만 사용한다.
|
||||
- 최신 글/검색/상세 조회까지만 다룬다.
|
||||
- 댓글, 투표, 로그인, 개인화 상태는 다루지 않는다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 최신 글을 훑을 때는 목록 조회부터 실행한다.
|
||||
2. 원하는 주제가 있으면 제목/요약/작성자 기준 검색으로 좁힌다.
|
||||
3. 특정 글을 확인할 때는 링크/id/토픽 번호 일부로 상세 조회한다.
|
||||
|
||||
## 예시
|
||||
|
||||
최신 글 목록:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list --limit 5
|
||||
```
|
||||
|
||||
검색:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py search --query Claude --limit 5
|
||||
```
|
||||
|
||||
상세:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py detail --id 28439
|
||||
```
|
||||
|
||||
오프라인 fixture 또는 저장된 feed로 검증할 때:
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list \
|
||||
--feed-file scripts/fixtures/geeknews-feed.xml \
|
||||
--limit 3
|
||||
```
|
||||
|
||||
## 출력에서 확인할 점
|
||||
|
||||
- `source.feed_url` 이 GeekNews RSS feed를 가리키는지
|
||||
- `items[].title`, `items[].link`, `items[].author_name`, `items[].summary` 가 함께 내려오는지
|
||||
- 상세 조회에서 `item.content_html` 과 `item.summary` 가 모두 포함되는지
|
||||
- 검색 결과가 제목/요약/작성자 기준으로 보수적으로 매칭되는지
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- RSS 피드 기반이라 원문 전체/댓글/메타데이터는 제한적일 수 있다.
|
||||
- FeedBurner 응답이 느리거나 실패하면 재시도하거나 직접 링크를 여는 fallback이 필요하다.
|
||||
- 상세 조회는 feed에 포함된 `content` 범위까지만 보장한다.
|
||||
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 문서에 추가하지 않는다.
|
||||
- 로그인은 반드시 사용자가 브라우저 안에서 직접 수행한다.
|
||||
- 이 기능은 조회/영수증 보조까지만 다루며 장기 무인 자동화는 약속하지 않는다.
|
||||
|
|
@ -15,18 +15,19 @@
|
|||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- `DATA_GO_KR_API_KEY`가 설정된 proxy 접근 가능 환경
|
||||
- 원본 API 접근 가능 환경
|
||||
- API 키 주입용 proxy 접근 가능 환경
|
||||
|
||||
## 기본 조회 예시
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구'
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
현재 proxy가 패스스루하는 파라미터는 `pageNo`, `numOfRows`, `cond[SGG_NM::LIKE]` 뿐이며, `returnType`은 항상 `json`으로 강제된다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용한다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
|
||||
클라이언트는 **`cond[SGG_NM::LIKE]`** 와 **`pageNo` / `numOfRows`**(또는 `page_no` / `num_of_rows`)를 **함께** 넘긴다. `pageNo` / `numOfRows` 값은 **반드시 `1` / `100`** 이어야 하고, 그 외 값이나 숫자만으로 표현되지 않는 문자열이면 proxy가 **`400`** 을 반환하고 upstream을 호출하지 않는다. upstream에는 항상 `pageNo=1`, `numOfRows=100`만 전달된다. `returnType`은 항상 `json`으로 강제된다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
|
||||
|
||||
## 조회 흐름 권장 순서
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@ 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/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`)
|
||||
- `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/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`)
|
||||
- `GET /v1/neis/school-search` (나이스 학교기본정보, `KEDU_INFO_KEY`)
|
||||
- `GET /v1/neis/school-meal` (나이스 급식식단정보, `KEDU_INFO_KEY`)
|
||||
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
|
||||
|
|
@ -34,11 +38,13 @@ 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=...`
|
||||
- `DATA_GO_KR_API_KEY=...` (생활쓰레기 배출정보 upstream key)
|
||||
- `DATA_GO_KR_API_KEY=...`
|
||||
- `KEDU_INFO_KEY=...` (나이스 교육정보 개방 포털 Open API 인증키)
|
||||
- `KRX_API_KEY=...`
|
||||
- `KSKILL_PROXY_PORT=4020`
|
||||
|
||||
## 프로덕션 배포 구조
|
||||
|
|
@ -101,6 +107,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
|
||||
|
|
@ -127,17 +141,6 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/opinet/detail' \
|
|||
--data-urlencode 'id=A0009905'
|
||||
```
|
||||
|
||||
생활쓰레기 배출정보 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
이 endpoint 는 `DATA_GO_KR_API_KEY`를 프록시 서버에서만 주입하고 `returnType=json`을 강제합니다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용합니다.
|
||||
|
||||
나이스 학교 검색·급식 endpoint (학교 급식 식단 스킬에서 사용):
|
||||
|
||||
```bash
|
||||
|
|
@ -153,6 +156,33 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-meal' \
|
|||
--data-urlencode 'mealDate=20260410'
|
||||
```
|
||||
|
||||
생활쓰레기 배출정보 endpoint. 쿼리에 **`pageNo`와 `numOfRows`를 반드시 포함**하고, 값은 각각 **`1`**, **`100`**만 허용한다(`page_no` / `num_of_rows` 동일). 누락·다른 값·숫자만이 아닌 문자열이면 **`400`**(upstream 미호출):
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
한국 주식 검색 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
|
||||
|
|
@ -168,5 +198,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 성공 경로는 해당 서비스 활용승인/키 상태가 준비된 환경에서 바로 이어서 검증할 수 있다.
|
||||
63
docs/features/naver-blog-research.md
Normal file
63
docs/features/naver-blog-research.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# 네이버 블로그 리서치 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 네이버 블로그 키워드 검색 (관련도순/최신순 정렬)
|
||||
- 블로그 포스트 원문 텍스트 추출
|
||||
- 블로그 포스트 내 이미지 URL 추출 및 로컬 다운로드
|
||||
- 구글 검색과 병행한 한국어 콘텐츠 교차 검증 리서치
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- `python3` 3.8+
|
||||
- 인터넷 연결
|
||||
- API 키 불필요
|
||||
|
||||
## 입력값
|
||||
|
||||
- 검색: 검색어 문자열 (예: `"서울 맛집 추천"`)
|
||||
- 원문 읽기: 네이버 블로그 포스트 URL (PC 또는 모바일)
|
||||
- 이미지 다운로드: 이미지 URL 목록 또는 `naver_read.py` 파이프 출력
|
||||
|
||||
## 공식 표면
|
||||
|
||||
- 검색: `https://search.naver.com/search.naver?where=blog&query={query}`
|
||||
- 블로그 원문 (모바일): `https://m.blog.naver.com/{userId}/{postId}`
|
||||
- 이미지 CDN: `blogfiles.naver.net`, `postfiles.pstatic.net`
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. `naver_search.py`로 네이버 블로그 검색 실행
|
||||
2. 검색 결과에서 상위 3~5개 포스트 선택
|
||||
3. `naver_read.py`로 선택한 포스트의 원문 읽기
|
||||
4. 필요 시 `naver_download_images.py`로 이미지 로컬 저장
|
||||
5. 구글 검색(WebSearch) 결과와 교차 검증하여 정보 신뢰도 확보
|
||||
|
||||
## 예시
|
||||
|
||||
블로그 검색:
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_search.py "제주도 여행 코스" --count 5 --sort sim
|
||||
```
|
||||
|
||||
블로그 원문 읽기:
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_read.py "https://blog.naver.com/user123/224212849946"
|
||||
```
|
||||
|
||||
이미지 다운로드:
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_read.py "https://blog.naver.com/user123/224212849946" \
|
||||
| python3 scripts/naver_download_images.py --output ./images/ --max 5
|
||||
```
|
||||
|
||||
## 주의 사항
|
||||
|
||||
- 네이버 검색엔진에 직접 요청하므로 대량 자동화 시 IP 차단 가능성이 있다. 한 세션에 과도한 요청을 자제한다.
|
||||
- 이 스킬은 소량·비상업적 콘텐츠 리서치 용도로 설계되었다.
|
||||
- 네이버 HTML 구조 변경 시 파싱이 실패할 수 있다. 에러 발생 시 스크립트 업데이트가 필요하다.
|
||||
- PC 버전(`blog.naver.com`)은 iframe 구조여서 모바일 버전(`m.blog.naver.com`)을 사용한다.
|
||||
- 블로그 출처(URL, 작성자)를 사용자에게 반드시 함께 안내한다.
|
||||
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(...)` 추출을 붙입니다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
|
|
|
|||
109
docs/install.md
109
docs/install.md
|
|
@ -49,25 +49,36 @@ 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 geeknews-search \
|
||||
--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 k-schoollunch-menu \
|
||||
--skill korean-character-count
|
||||
```
|
||||
|
||||
인증이 필요한 기능만 부분 설치할 때도 `k-skill-setup` 은 같이 넣는다.
|
||||
|
|
@ -79,9 +90,16 @@ 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 geeknews-search \
|
||||
--skill korea-weather \
|
||||
--skill fine-dust-location
|
||||
```
|
||||
|
||||
|
|
@ -101,9 +119,29 @@ 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)를 본다.
|
||||
|
||||
`k-schoollunch-menu` 는 별도 설치 없이 `k-skill-proxy`의 `/v1/neis/school-search`, `/v1/neis/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서만 나이스 Open API `KEY`로 주입한다. 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다. 자세한 사용법은 [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.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
|
||||
|
||||
|
|
@ -136,6 +174,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
|
||||
|
|
@ -170,7 +255,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)"
|
||||
```
|
||||
|
||||
|
|
@ -196,12 +281,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도 없으면
|
||||
|
|
@ -219,9 +318,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,33 @@
|
|||
- K리그 경기 결과 조회 스킬 출시
|
||||
- LCK 경기 분석 스킬 출시
|
||||
- 토스증권 조회 스킬 출시
|
||||
- 하이패스 영수증 발급 스킬 출시
|
||||
- 로또 당첨번호
|
||||
- 서울 지하철 도착 정보
|
||||
- 한국 날씨 조회 스킬 출시
|
||||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 한강 수위 정보 조회 스킬 출시
|
||||
- 한국 법령 검색 스킬 출시
|
||||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 의약품 안전 체크 스킬 출시
|
||||
- 식품 안전 체크 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
- 마켓컬리 상품 조회 스킬 출시
|
||||
- 올리브영 검색 스킬 출시
|
||||
- 쿠팡 상품 검색 스킬 출시 (coupang-mcp 기반)
|
||||
- 번개장터 검색 스킬 출시
|
||||
- 중고차 가격 조회 스킬 출시
|
||||
- 한국어 맞춤법 검사 스킬 출시
|
||||
- 한국어 글자 수 세기 스킬 출시
|
||||
- 긱뉴스 조회 스킬 출시
|
||||
|
||||
## v1.5 candidates
|
||||
|
||||
|
|
@ -109,10 +119,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를 쓰므로 사용자 쪽 키가 불필요하다(단, hosted 프록시 운영 측에서 `DATA_GO_KR_API_KEY`·`KEDU_INFO_KEY` 등은 서버에 설정되어 있어야 한다).
|
||||
|
||||
## 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,9 +42,11 @@ 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` 가 불필요하다.
|
||||
|
||||
생활쓰레기 배출정보 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
|
||||
|
||||
## 확인
|
||||
|
||||
|
|
@ -67,11 +70,15 @@ 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 사용) |
|
||||
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host 사용) |
|
||||
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 한국 날씨 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 생활쓰레기 배출정보 조회 | 사용자 시크릿 불필요 (프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted/self-host; API 호출 시 `pageNo=1`, `numOfRows=100` 필수) |
|
||||
| 학교 급식 식단 조회 | 사용자 시크릿 불필요 (프록시에 `KEDU_INFO_KEY`가 설정된 hosted/self-host 사용) |
|
||||
|
||||
## 다음에 볼 문서
|
||||
|
|
@ -79,10 +86,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/household-waste-info.md)
|
||||
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.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,20 @@
|
|||
- 조선왕조실록 메인: 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
|
||||
- GeekNews public RSS/Atom feed: https://feeds.feedburner.com/geeknews-feed
|
||||
- GeekNews home: https://news.hada.io
|
||||
- 기상청 단기예보 조회서비스: 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 +114,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,5 +3,6 @@ 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
|
||||
|
|
|
|||
79
geeknews-search/SKILL.md
Normal file
79
geeknews-search/SKILL.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
name: geeknews-search
|
||||
description: GeekNews public RSS/Atom feed로 긱뉴스 게시물을 조회, 검색, 상세 확인하는 읽기 전용 스킬.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: news
|
||||
locale: ko-KR
|
||||
source: geeknews-rss
|
||||
---
|
||||
|
||||
# GeekNews Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
GeekNews 공개 RSS/Atom 피드(`https://feeds.feedburner.com/geeknews-feed`)를 사용해 최신 글을 읽기 전용으로 조회한다.
|
||||
|
||||
- 최신 글 목록 조회
|
||||
- 제목/요약/작성자 기준 검색
|
||||
- 항목 id/link 기준 상세 확인
|
||||
|
||||
## When to use
|
||||
|
||||
- "긱뉴스 오늘 뭐 올라왔어?"
|
||||
- "긱뉴스에서 Claude 관련 글 찾아줘"
|
||||
- "이 GeekNews 글 요약/링크 확인해줘"
|
||||
|
||||
## Inputs
|
||||
|
||||
- 기본: 별도 인증 없이 public feed만 사용
|
||||
- 목록 조회: `limit`
|
||||
- 검색: `query`, 선택 `limit`
|
||||
- 상세 조회: `id` 또는 링크/토픽 번호 일부
|
||||
|
||||
## Official surface
|
||||
|
||||
- GeekNews RSS/Atom feed: `https://feeds.feedburner.com/geeknews-feed`
|
||||
- GeekNews home: `https://news.hada.io`
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1) List recent entries
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py list --limit 10
|
||||
```
|
||||
|
||||
### 2) Search the feed conservatively
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py search --query Claude --limit 5
|
||||
```
|
||||
|
||||
검색은 제목, 요약, 작성자, 링크/id 기준으로만 동작한다.
|
||||
|
||||
### 3) Inspect a specific item
|
||||
|
||||
```bash
|
||||
python3 scripts/geeknews_search.py detail --id 28439
|
||||
```
|
||||
|
||||
상세 조회는 RSS 피드에 포함된 `content`/요약과 원문 링크를 함께 돌려준다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 최신 GeekNews 글 목록을 바로 보여줄 수 있다.
|
||||
- 키워드 검색 결과에서 제목/링크/작성자/요약을 정리할 수 있다.
|
||||
- 특정 항목의 RSS 기반 내용을 보수적으로 확인하고 원문 링크를 함께 제시할 수 있다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- FeedBurner/GeekNews feed가 일시적으로 응답하지 않을 수 있다.
|
||||
- RSS 피드가 제공하는 범위를 넘는 전체 본문/댓글/투표 정보는 포함되지 않는다.
|
||||
- HTML 요약은 feed 원문 기준이라 일부가 잘릴 수 있다.
|
||||
|
||||
## Notes
|
||||
|
||||
- v1은 RSS-first, read-only 범위다.
|
||||
- 비공식 API나 로그인 세션에 의존하지 않는다.
|
||||
- 테스트/오프라인 검증 시 `--feed-file` 로 저장된 Atom XML을 넣을 수 있다.
|
||||
296
geeknews-search/scripts/geeknews_search.py
Executable file
296
geeknews-search/scripts/geeknews_search.py
Executable file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
GEEKNEWS_FEED_URL = "https://feeds.feedburner.com/geeknews-feed"
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(part.strip() for part in self.parts if part.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
link: str
|
||||
published: str | None
|
||||
updated: str | None
|
||||
author_name: str | None
|
||||
author_url: str | None
|
||||
summary: str
|
||||
content_html: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsFeed:
|
||||
title: str
|
||||
source_id: str | None
|
||||
updated: str | None
|
||||
home_url: str | None
|
||||
feed_url: str | None
|
||||
category: str | None
|
||||
items: list[GeekNewsItem]
|
||||
|
||||
def source_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"id": self.source_id,
|
||||
"updated": self.updated,
|
||||
"home_url": self.home_url,
|
||||
"feed_url": self.feed_url,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
|
||||
def _strip_cdata(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("<![CDATA[") and stripped.endswith("]]>"):
|
||||
return stripped[9:-3]
|
||||
return stripped
|
||||
|
||||
|
||||
def _collapse_whitespace(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _clean_xml_text(value: str | None) -> str:
|
||||
return _collapse_whitespace(unescape(_strip_cdata(value)))
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return _collapse_whitespace(unescape(parser.text()))
|
||||
|
||||
|
||||
def _first_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _clean_xml_text(match.group(1))
|
||||
|
||||
|
||||
def _first_raw_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _strip_cdata(match.group(1)).strip()
|
||||
|
||||
|
||||
def _first_link_href(block: str) -> str | None:
|
||||
patterns = (
|
||||
r"<link\b[^>]*rel=['\"]alternate['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
r"<link\b[^>]*href=['\"]([^'\"]+)['\"]",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, block)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return None
|
||||
|
||||
|
||||
def _link_href(block: str, *, rel: str | None = None) -> str | None:
|
||||
if rel:
|
||||
match = re.search(
|
||||
rf"<link\b[^>]*(?:rel|ref)=['\"]{re.escape(rel)}['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
block,
|
||||
)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return _first_link_href(block)
|
||||
|
||||
|
||||
def _feed_prefix(xml_text: str) -> str:
|
||||
if "<entry" not in xml_text:
|
||||
return xml_text
|
||||
return xml_text.split("<entry", 1)[0]
|
||||
|
||||
|
||||
def _entry_blocks(xml_text: str) -> list[str]:
|
||||
return re.findall(r"<entry\b[^>]*>(.*?)</entry>", xml_text, re.DOTALL)
|
||||
|
||||
|
||||
def _validate_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be positive")
|
||||
return limit
|
||||
|
||||
|
||||
def load_feed(xml_text: str) -> GeekNewsFeed:
|
||||
prefix = _feed_prefix(xml_text)
|
||||
items = []
|
||||
for entry in _entry_blocks(xml_text):
|
||||
author_block_match = re.search(r"<author\b[^>]*>(.*?)</author>", entry, re.DOTALL)
|
||||
author_block = author_block_match.group(1) if author_block_match else ""
|
||||
content_html = (_first_raw_tag(entry, "content") or "").strip()
|
||||
items.append(
|
||||
GeekNewsItem(
|
||||
id=_first_tag(entry, "id") or "",
|
||||
title=_first_tag(entry, "title") or "",
|
||||
link=_first_link_href(entry) or (_first_tag(entry, "id") or ""),
|
||||
published=_first_tag(entry, "published") or _first_tag(entry, "updated"),
|
||||
updated=_first_tag(entry, "updated"),
|
||||
author_name=_first_tag(author_block, "name"),
|
||||
author_url=_first_tag(author_block, "uri"),
|
||||
summary=_html_to_text(content_html),
|
||||
content_html=content_html,
|
||||
)
|
||||
)
|
||||
|
||||
category_match = re.search(r"<category\b[^>]*term=['\"]([^'\"]+)['\"]", prefix)
|
||||
return GeekNewsFeed(
|
||||
title=_first_tag(prefix, "title") or "GeekNews",
|
||||
source_id=_first_tag(prefix, "id"),
|
||||
updated=_first_tag(prefix, "updated"),
|
||||
home_url=_link_href(prefix, rel="alternate"),
|
||||
feed_url=_link_href(prefix, rel="self") or _first_tag(prefix, "id"),
|
||||
category=category_match.group(1) if category_match else None,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
def list_items(feed: GeekNewsFeed, limit: int = 10) -> list[GeekNewsItem]:
|
||||
return feed.items[:_validate_limit(limit)]
|
||||
|
||||
|
||||
def search_items(feed: GeekNewsFeed, query: str, limit: int = 10) -> list[GeekNewsItem]:
|
||||
if not query.strip():
|
||||
raise ValueError("query is required")
|
||||
limit = _validate_limit(limit)
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in feed.items:
|
||||
haystack = "\n".join(
|
||||
part
|
||||
for part in (
|
||||
item.title,
|
||||
item.summary,
|
||||
item.author_name or "",
|
||||
item.author_url or "",
|
||||
item.id,
|
||||
item.link,
|
||||
)
|
||||
if part
|
||||
).casefold()
|
||||
if needle in haystack:
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
def get_item_detail(feed: GeekNewsFeed, lookup: str) -> GeekNewsItem:
|
||||
normalized_lookup = lookup.strip().casefold()
|
||||
if not normalized_lookup:
|
||||
raise ValueError("lookup is required")
|
||||
for item in feed.items:
|
||||
candidates = [item.id, item.link, item.title]
|
||||
lowered = [candidate.casefold() for candidate in candidates if candidate]
|
||||
if normalized_lookup in lowered or any(normalized_lookup in candidate for candidate in lowered):
|
||||
return item
|
||||
raise LookupError(f"No GeekNews entry matched: {lookup}")
|
||||
|
||||
|
||||
def _serialize_items(items: list[GeekNewsItem]) -> list[dict[str, object]]:
|
||||
return [item.to_dict() for item in items]
|
||||
|
||||
|
||||
def build_list_payload(feed: GeekNewsFeed, limit: int = 10) -> dict[str, object]:
|
||||
items = list_items(feed, limit=limit)
|
||||
return {"source": feed.source_dict(), "count": len(items), "items": _serialize_items(items)}
|
||||
|
||||
|
||||
def build_search_payload(feed: GeekNewsFeed, query: str, limit: int = 10) -> dict[str, object]:
|
||||
items = search_items(feed, query=query, limit=limit)
|
||||
return {
|
||||
"source": feed.source_dict(),
|
||||
"query": query,
|
||||
"count": len(items),
|
||||
"items": _serialize_items(items),
|
||||
}
|
||||
|
||||
|
||||
def build_detail_payload(feed: GeekNewsFeed, lookup: str) -> dict[str, object]:
|
||||
item = get_item_detail(feed, lookup)
|
||||
return {"source": feed.source_dict(), "item": item.to_dict()}
|
||||
|
||||
|
||||
def fetch_feed(url: str = GEEKNEWS_FEED_URL, timeout: int = 20) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "k-skill-geeknews/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(charset, errors="replace")
|
||||
|
||||
|
||||
def _add_feed_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--feed-url", default=GEEKNEWS_FEED_URL, help="기본값: GeekNews public feed URL")
|
||||
parser.add_argument("--feed-file", help="테스트/오프라인 검증용 로컬 Atom XML 파일")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Read GeekNews entries from the public RSS/Atom feed.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="최신 GeekNews 항목 목록")
|
||||
_add_feed_source_args(list_parser)
|
||||
list_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="제목/요약/작성자 기준 검색")
|
||||
_add_feed_source_args(search_parser)
|
||||
search_parser.add_argument("--query", required=True)
|
||||
search_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
detail_parser = subparsers.add_parser("detail", help="항목 상세 확인")
|
||||
_add_feed_source_args(detail_parser)
|
||||
detail_parser.add_argument("--id", required=True, help="entry id/link/topic id 일부")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _load_feed_text(args: argparse.Namespace) -> str:
|
||||
if args.feed_file:
|
||||
return Path(args.feed_file).read_text(encoding="utf-8")
|
||||
return fetch_feed(url=args.feed_url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
feed = load_feed(_load_feed_text(args))
|
||||
|
||||
if args.command == "list":
|
||||
payload = build_list_payload(feed, limit=args.limit)
|
||||
elif args.command == "search":
|
||||
payload = build_search_payload(feed, query=args.query, limit=args.limit)
|
||||
else:
|
||||
payload = build_detail_payload(feed, lookup=args.id)
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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를 선택해 영수증 팝업 진입을 시도할 수 있다.
|
||||
- 세션 종료 응답을 감지하면 재로그인을 요구한다.
|
||||
|
|
@ -17,7 +17,7 @@ metadata:
|
|||
|
||||
- 기본 조회 단위는 시군구명(`SGG_NM`)이다.
|
||||
- 응답은 사용자에게 이해하기 쉬운 요약 형태로 정리한다.
|
||||
- 기본 호출 URL은 proxy `https://k-skill-proxy.nomadamas.org/v1/household-waste/info` 를 기준으로 한다.
|
||||
- Base URL은 원본 API(`https://apis.data.go.kr/1741000/household_waste_info`)를 기준으로 한다.
|
||||
- `serviceKey`(`DATA_GO_KR_API_KEY`)만 proxy 서버에서 주입/관리한다.
|
||||
|
||||
## When to use
|
||||
|
|
@ -31,7 +31,8 @@ metadata:
|
|||
|
||||
- 인터넷 연결
|
||||
- `curl`, `python3` 사용 가능 환경
|
||||
- `DATA_GO_KR_API_KEY`가 설정된 proxy 접근 가능 환경
|
||||
- 원본 API 접근 가능 환경
|
||||
- API 키 주입용 proxy 접근 가능 환경
|
||||
|
||||
## Credential requirements
|
||||
|
||||
|
|
@ -57,13 +58,12 @@ metadata:
|
|||
|
||||
추가 client API 레이어는 불필요하다. Base URL은 원본 API를 기준으로 유지한다.
|
||||
|
||||
현재 proxy가 지원하는 쿼리 파라미터(이외 값은 무시된다):
|
||||
현재 proxy가 지원하는 쿼리 파라미터:
|
||||
|
||||
- `serviceKey`: proxy가 서버 측에서 주입하는 인증키 (`DATA_GO_KR_API_KEY`) — 클라이언트에서 전달 금지
|
||||
- `pageNo`: 필수, 정확히 `1`만 허용
|
||||
- `numOfRows`: 필수, 정확히 `100`만 허용
|
||||
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
|
||||
- `cond[SGG_NM::LIKE]`: 시군구명 포함 검색 (필수)
|
||||
- `pageNo` / `numOfRows`(또는 `page_no` / `num_of_rows`): **필수**, 값은 **반드시 `1` / `100`** — 그 외 값·비정수(숫자만 아닌) 문자열은 **`400`**. upstream에는 항상 1페이지·100건만 전달한다.
|
||||
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
|
||||
- `serviceKey`: proxy가 서버 측에서 주입 — 클라이언트에서 전달 금지
|
||||
|
||||
> 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 proxy 라우트에서 패스스루되지 않는다. 사용자가 보내는 일반적인 질의("강남구 쓰레기 배출 요일")는 시군구 기준 검색만으로 충분하므로, 필요하다면 응답에서 `DAT_UPDT_PNT` 기준으로 클라이언트에서 정렬한다.
|
||||
|
||||
|
|
@ -86,12 +86,12 @@ proxy가 `serviceKey`를 서버 측에서 주입한 뒤 원본 API로 전달한
|
|||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
||||
--data-urlencode "cond[SGG_NM::LIKE]=강남구" \
|
||||
--data-urlencode "pageNo=1" \
|
||||
--data-urlencode "numOfRows=100" \
|
||||
--data-urlencode "cond[SGG_NM::LIKE]=강남구"
|
||||
--data-urlencode "numOfRows=100"
|
||||
```
|
||||
|
||||
`returnType`은 proxy가 항상 `json`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다. `pageNo`는 정확히 `1`, `numOfRows`는 정확히 `100`만 허용한다.
|
||||
`returnType`은 proxy가 항상 `json`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL`이 있으면 그 값을 사용하고, 없으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
|
|
@ -116,7 +116,8 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
|
|||
- 프록시 서버에 `DATA_GO_KR_API_KEY`가 없거나 만료된 경우 (`serviceKey` 주입 실패)
|
||||
- 검색 지역명이 API 데이터와 불일치하여 결과가 비는 경우
|
||||
- 공공데이터 API 일시 장애/트래픽 제한
|
||||
- 필수 파라미터 누락(`cond[SGG_NM::LIKE]`, `pageNo`, `numOfRows`)
|
||||
- 필수 파라미터 누락(`cond[SGG_NM::LIKE]`, 또는 `pageNo` / `numOfRows` 미전달)
|
||||
- `pageNo` / `numOfRows` 값이 `1` / `100`이 아니거나, 숫자만으로 표현되지 않은 문자열인 경우(proxy `400`, upstream 미호출)
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -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,19 +77,23 @@ 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` 가 불필요하다.
|
||||
|
||||
학교 급식 식단 조회는 `k-skill-proxy`의 `/v1/neis/school-search`, `/v1/neis/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서 주입/관리하므로 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다.
|
||||
학교 급식 식단 조회는 `k-skill-proxy`의 `/v1/neis/school-search`·`/v1/neis/school-meal`을 호출하고, `KEDU_INFO_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에 따라 확보한다.
|
||||
|
|
@ -100,10 +105,13 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`(`DATA_GO_KR_API_KEY`)는 proxy 서버 주입)
|
||||
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 proxy 서버 주입)
|
||||
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
- 생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 (`serviceKey`는 proxy 서버 주입, 호출 시 `pageNo=1`·`numOfRows=100` 필수)
|
||||
- 학교 급식 식단 조회: 사용자 시크릿 불필요 (`KEDU_INFO_KEY`는 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())
|
||||
3
naver-blog-research/.gitignore
vendored
Normal file
3
naver-blog-research/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
naver-images/
|
||||
138
naver-blog-research/SKILL.md
Normal file
138
naver-blog-research/SKILL.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
name: naver-blog-research
|
||||
description: Search Naver blogs, read full post content, and download images using only python3 stdlib — no API key required.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: research
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# 네이버 블로그 리서치
|
||||
|
||||
## What this skill does
|
||||
|
||||
네이버 블로그를 검색하고, 개별 포스트의 원문을 읽고, 이미지를 로컬에 다운로드한다.
|
||||
|
||||
- API 키 없이 `python3` 표준 라이브러리만으로 동작한다.
|
||||
- 검색 결과를 구조화된 JSON으로 출력한다.
|
||||
- 모바일 버전(`m.blog.naver.com`)을 이용해 iframe 없이 본문을 직접 추출한다.
|
||||
- 블로그 이미지 CDN(`blogfiles.naver.net`, `postfiles.pstatic.net`)에서 이미지를 다운로드한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "네이버 블로그에서 결혼식 체크리스트 검색해줘"
|
||||
- "네이버 블로그 리서치 해줘"
|
||||
- "한국 블로그에서 관련 정보 조사해줘"
|
||||
- "네이버 블로그 글 읽어줘"
|
||||
- "이 네이버 블로그 포스트에서 이미지 다운로드해줘"
|
||||
- 한국어 콘텐츠 리서치에서 구글 외 네이버 블로그 소스가 필요한 상황
|
||||
|
||||
## When not to use
|
||||
|
||||
- 네이버 뉴스, 카페, 지식iN 등 블로그 외 네이버 서비스 검색
|
||||
- 대량 크롤링/스크래핑 (한 세션에 수십 건 이상의 요청)
|
||||
- 상업적 데이터 수집
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `python3` 3.8+
|
||||
- 이 스킬 디렉토리의 `scripts/` 안에 포함된 helper 스크립트
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 네이버 블로그 검색
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_search.py "검색어" --count 10 --sort sim
|
||||
```
|
||||
|
||||
| 인자 | 필수 | 설명 | 기본값 |
|
||||
|------|------|------|--------|
|
||||
| query | O | 검색어 | - |
|
||||
| --count | X | 결과 수 (최대 30) | 10 |
|
||||
| --sort | X | sim(관련도), date(최신) | sim |
|
||||
| --timeout | X | 요청 타임아웃(초) | 15 |
|
||||
|
||||
출력 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "결혼식 체크리스트",
|
||||
"total_results": 7,
|
||||
"results": [
|
||||
{
|
||||
"title": "결혼식 체크리스트 총정리",
|
||||
"url": "https://blog.naver.com/user123/224212849946",
|
||||
"mobile_url": "https://m.blog.naver.com/user123/224212849946",
|
||||
"snippet": "결혼식 1주일 전에 반드시 확인해야 할...",
|
||||
"author": "user123"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 블로그 원문 읽기
|
||||
|
||||
검색 결과에서 관심 있는 포스트의 URL을 선택하여 원문을 읽는다.
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_read.py "https://blog.naver.com/user123/224212849946"
|
||||
```
|
||||
|
||||
| 인자 | 필수 | 설명 | 기본값 |
|
||||
|------|------|------|--------|
|
||||
| url | O | 블로그 포스트 URL (PC 또는 모바일) | - |
|
||||
| --no-images | X | 이미지 URL 제외 | false |
|
||||
| --max-length | X | 본문 최대 글자 수 (0=무제한) | 0 |
|
||||
| --timeout | X | 요청 타임아웃(초) | 20 |
|
||||
|
||||
PC URL을 넣어도 자동으로 모바일 URL로 변환하여 요청한다.
|
||||
|
||||
### 3. 이미지 다운로드 (필요 시)
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_download_images.py --urls "url1,url2,url3" --output ./images/
|
||||
```
|
||||
|
||||
또는 `naver_read.py` 결과를 파이프로 전달:
|
||||
|
||||
```bash
|
||||
python3 scripts/naver_read.py "https://..." | python3 scripts/naver_download_images.py --output ./images/
|
||||
```
|
||||
|
||||
| 인자 | 필수 | 설명 | 기본값 |
|
||||
|------|------|------|--------|
|
||||
| --urls | X | 쉼표 구분 이미지 URL | - |
|
||||
| --output | X | 저장 디렉토리 | ./naver-images/ |
|
||||
| --max | X | 최대 다운로드 수 | 10 |
|
||||
| --timeout | X | 요청 타임아웃(초) | 15 |
|
||||
|
||||
### 추천 워크플로우
|
||||
|
||||
1. `naver_search.py`로 검색 → 상위 3~5개 결과 확인
|
||||
2. 관련도 높은 포스트를 `naver_read.py`로 원문 읽기
|
||||
3. 필요 시 `naver_download_images.py`로 이미지 저장
|
||||
4. WebSearch(구글) 결과와 교차 검증하여 정보 신뢰도 높이기
|
||||
|
||||
## Response policy
|
||||
|
||||
- 검색 결과와 본문은 사용자에게 요약하여 전달한다.
|
||||
- 블로그 출처(URL, 작성자)를 반드시 함께 안내한다.
|
||||
- 한 세션에 과도한 요청(수십 건 이상)을 자제한다.
|
||||
- 이미지 다운로드 시 사용자에게 저장 경로를 안내한다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 검색 결과가 JSON으로 정상 출력된다.
|
||||
- 블로그 원문 텍스트가 추출된다.
|
||||
- 필요한 이미지가 로컬에 저장된다.
|
||||
- 출처가 명시된다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 네이버 검색엔진을 직접 요청하므로 대량/자동화 사용 시 IP 차단 가능성이 있다.
|
||||
- 이 스킬은 소량, 비상업적 콘텐츠 리서치 용도로 설계되었다.
|
||||
- 네이버 HTML 구조는 변경될 수 있어, 파싱 실패 시 에러 메시지를 확인하고 스크립트 업데이트가 필요할 수 있다.
|
||||
- PC 버전(`blog.naver.com`)은 iframe 구조여서 모바일 버전(`m.blog.naver.com`)을 사용한다.
|
||||
58
naver-blog-research/scripts/_naver_http.py
Normal file
58
naver-blog-research/scripts/_naver_http.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Shared HTTP utilities for Naver blog scripts (SSL handling, URL validation, urlopen wrapper)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
_ssl_ctx_secure: ssl.SSLContext | None = None
|
||||
_ssl_ctx_insecure: ssl.SSLContext | None = None
|
||||
|
||||
|
||||
def _get_ssl_context(*, insecure: bool = False) -> ssl.SSLContext:
|
||||
global _ssl_ctx_secure, _ssl_ctx_insecure
|
||||
if insecure:
|
||||
if _ssl_ctx_insecure is None:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
_ssl_ctx_insecure = ctx
|
||||
return _ssl_ctx_insecure
|
||||
if _ssl_ctx_secure is None:
|
||||
_ssl_ctx_secure = ssl.create_default_context()
|
||||
return _ssl_ctx_secure
|
||||
|
||||
|
||||
_NAVER_DOMAINS = (".naver.com", ".naver.net", ".pstatic.net")
|
||||
|
||||
|
||||
def is_naver_url(url: str) -> bool:
|
||||
host = urllib.parse.urlparse(url).hostname or ""
|
||||
return any(host == d.lstrip(".") or host.endswith(d) for d in _NAVER_DOMAINS)
|
||||
|
||||
|
||||
def urlopen(request: urllib.request.Request, timeout: int, *, insecure: bool = False):
|
||||
"""urlopen with explicit SSL insecure mode for Naver domains.
|
||||
|
||||
When *insecure* is True and the target is a Naver domain, SSL certificate
|
||||
verification is skipped. A warning is printed to stderr on every call so
|
||||
the caller is always aware.
|
||||
"""
|
||||
if insecure:
|
||||
if not is_naver_url(request.full_url):
|
||||
raise ValueError("insecure 모드는 네이버 도메인에만 사용할 수 있습니다.")
|
||||
print(
|
||||
"[warn] SSL 인증서 검증이 비활성화되었습니다. 연결이 안전하지 않을 수 있습니다.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return urllib.request.urlopen(
|
||||
request, timeout=timeout, context=_get_ssl_context(insecure=True),
|
||||
)
|
||||
return urllib.request.urlopen(request, timeout=timeout, context=_get_ssl_context())
|
||||
233
naver-blog-research/scripts/naver_download_images.py
Normal file
233
naver-blog-research/scripts/naver_download_images.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _naver_http import is_naver_url, urlopen
|
||||
|
||||
DEFAULT_OUTPUT_DIR = "./naver-images"
|
||||
DEFAULT_MAX = 10
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
||||
"Referer": "https://m.blog.naver.com/",
|
||||
"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"
|
||||
),
|
||||
}
|
||||
|
||||
CONTENT_TYPE_TO_EXT = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/bmp": ".bmp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
_MAGIC_BYTES = (
|
||||
(b"\x89PNG\r\n\x1a\n", ".png"),
|
||||
(b"GIF87a", ".gif"),
|
||||
(b"GIF89a", ".gif"),
|
||||
(b"RIFF", ".webp"), # WebP: RIFF....WEBP (check first 4 bytes)
|
||||
(b"BM", ".bmp"),
|
||||
)
|
||||
|
||||
|
||||
def guess_extension(url: str, content_type: str | None = None, data: bytes | None = None) -> str:
|
||||
if content_type:
|
||||
ct = content_type.split(";")[0].strip().lower()
|
||||
if ct in CONTENT_TYPE_TO_EXT:
|
||||
return CONTENT_TYPE_TO_EXT[ct]
|
||||
|
||||
lower_url = url.lower().split("?")[0]
|
||||
for ext in (".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"):
|
||||
if lower_url.endswith(ext):
|
||||
return ".jpg" if ext == ".jpeg" else ext
|
||||
|
||||
if data:
|
||||
for magic, ext in _MAGIC_BYTES:
|
||||
if data[:len(magic)] == magic:
|
||||
if ext == ".webp" and data[8:12] != b"WEBP":
|
||||
continue
|
||||
return ext
|
||||
if data[:2] in (b"\xff\xd8",):
|
||||
return ".jpg"
|
||||
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def download_image(url: str, output_path: str, output_dir: str, timeout: int = DEFAULT_TIMEOUT, *, insecure: bool = False) -> dict:
|
||||
"""Download a single image from a Naver CDN URL.
|
||||
|
||||
*output_dir* is used solely for path-traversal protection: the resolved
|
||||
*output_path* must reside inside *output_dir*.
|
||||
"""
|
||||
if not is_naver_url(url):
|
||||
return {"url": url, "error": "Not a Naver CDN URL. Skipped."}
|
||||
|
||||
real_dir = os.path.realpath(output_dir)
|
||||
if not os.path.realpath(output_path).startswith(real_dir + os.sep):
|
||||
return {"url": url, "error": "Output path escapes target directory. Skipped."}
|
||||
|
||||
request = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout, insecure=insecure) as response:
|
||||
data = response.read()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
except (urllib.error.HTTPError, urllib.error.URLError, OSError) as error:
|
||||
return {"url": url, "error": str(error)}
|
||||
|
||||
ext = guess_extension(url, content_type, data)
|
||||
if not os.path.splitext(output_path)[1]:
|
||||
output_path += ext
|
||||
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
size_kb = round(len(data) / 1024, 1)
|
||||
return {"url": url, "path": output_path, "size_kb": size_kb}
|
||||
|
||||
|
||||
def download_images(
|
||||
urls: list[str],
|
||||
output_dir: str = DEFAULT_OUTPUT_DIR,
|
||||
max_count: int = DEFAULT_MAX,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
*,
|
||||
insecure: bool = False,
|
||||
) -> dict:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
max_count = max(1, max_count)
|
||||
targets = urls[:max_count]
|
||||
downloaded: list[dict] = []
|
||||
failed: list[dict] = []
|
||||
|
||||
# index → result 순서를 보장하기 위해 dict로 매핑
|
||||
results_by_index: dict[int, dict] = {}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(4, max(1, len(targets)))) as executor:
|
||||
future_to_index = {}
|
||||
for i, url in enumerate(targets, start=1):
|
||||
filename = f"{i:03d}"
|
||||
output_path = os.path.join(output_dir, filename)
|
||||
future = executor.submit(download_image, url, output_path, output_dir, timeout, insecure=insecure)
|
||||
future_to_index[future] = i
|
||||
|
||||
for future in as_completed(future_to_index):
|
||||
idx = future_to_index[future]
|
||||
try:
|
||||
results_by_index[idx] = future.result()
|
||||
except Exception as exc:
|
||||
results_by_index[idx] = {"url": targets[idx - 1], "error": str(exc)}
|
||||
|
||||
# 원래 순서대로 정렬
|
||||
for idx in sorted(results_by_index):
|
||||
result = results_by_index[idx]
|
||||
if "error" in result:
|
||||
failed.append(result)
|
||||
else:
|
||||
downloaded.append(result)
|
||||
|
||||
return {
|
||||
"downloaded": len(downloaded),
|
||||
"files": downloaded,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Download images from Naver blog CDN URLs."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--urls", type=str, default="",
|
||||
help="Comma-separated image URLs.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=str, default=DEFAULT_OUTPUT_DIR,
|
||||
help=f"Output directory. Default: {DEFAULT_OUTPUT_DIR}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max", type=int, default=DEFAULT_MAX,
|
||||
help=f"Maximum number of images to download. Default: {DEFAULT_MAX}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=DEFAULT_TIMEOUT,
|
||||
help=f"HTTP request timeout in seconds. Default: {DEFAULT_TIMEOUT}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure", action="store_true",
|
||||
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def read_urls_from_stdin() -> list[str]:
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if isinstance(data, dict) and "images" in data:
|
||||
return [img["url"] for img in data["images"] if isinstance(img, dict) and img.get("url")]
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
u for item in data
|
||||
if (u := (item if isinstance(item, str) else item.get("url", "")))
|
||||
]
|
||||
if isinstance(data, dict):
|
||||
print(
|
||||
"[warn] stdin JSON에 'images' 키가 없습니다. "
|
||||
"naver_read.py 실행 시 --no-images 플래그를 사용하지 않았는지 확인하세요.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
||||
print(f"[warn] stdin JSON 파싱 실패: {exc}", file=sys.stderr)
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
|
||||
urls: list[str] = []
|
||||
|
||||
if args.urls:
|
||||
urls = [u.strip() for u in args.urls.split(",") if u.strip()]
|
||||
|
||||
if not urls and not sys.stdin.isatty():
|
||||
urls = read_urls_from_stdin()
|
||||
|
||||
if not urls:
|
||||
print(
|
||||
json.dumps({"error": "No image URLs provided. Use --urls or pipe naver_read.py output via stdin."}, ensure_ascii=False),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
result = download_images(
|
||||
urls,
|
||||
output_dir=args.output,
|
||||
max_count=args.max,
|
||||
timeout=args.timeout,
|
||||
insecure=args.insecure,
|
||||
)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
256
naver-blog-research/scripts/naver_read.py
Normal file
256
naver-blog-research/scripts/naver_read.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from html import unescape
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _naver_http import TAG_RE, is_naver_url, urlopen
|
||||
|
||||
MOBILE_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
||||
"User-Agent": MOBILE_UA,
|
||||
}
|
||||
|
||||
BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
|
||||
BLOCK_END_RE = re.compile(r"</(p|div|li)>", re.IGNORECASE)
|
||||
WHITESPACE_RE = re.compile(r"[ \t]+")
|
||||
BLANK_LINES_RE = re.compile(r"\n{3,}")
|
||||
|
||||
_IMG_CDN_HOSTS = r"(?:blogfiles\.naver\.net|postfiles\.pstatic\.net|mblogthumb-phinf\.pstatic\.net)"
|
||||
|
||||
IMAGE_LAZY_PATTERN = re.compile(
|
||||
rf'data-lazy-src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
|
||||
)
|
||||
IMAGE_SRC_PATTERN = re.compile(
|
||||
rf'src="(https?://{_IMG_CDN_HOSTS}[^"]+)"'
|
||||
)
|
||||
IMAGE_ALT_PATTERN = re.compile(
|
||||
r'alt="([^"]*)"'
|
||||
)
|
||||
|
||||
TITLE_PATTERN = re.compile(
|
||||
r'<title[^>]*>(.*?)</title>', re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
SCRIPT_STYLE_RE = re.compile(r"<(script|style|noscript)[^>]*>.*?</\1>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
PC_BLOG_RE = re.compile(r"^https?://blog\.naver\.com/")
|
||||
BLOG_ID_RE = re.compile(r"blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+)")
|
||||
|
||||
|
||||
def to_mobile_url(url: str) -> str:
|
||||
url = url.strip()
|
||||
url = PC_BLOG_RE.sub("https://m.blog.naver.com/", url)
|
||||
if not url.startswith("https://m.blog.naver.com/"):
|
||||
match = BLOG_ID_RE.search(url)
|
||||
if match:
|
||||
url = f"https://m.blog.naver.com/{match.group(1)}/{match.group(2)}"
|
||||
return url
|
||||
|
||||
|
||||
def fetch_blog_page(url: str, timeout: int = 20, *, insecure: bool = False) -> str:
|
||||
mobile_url = to_mobile_url(url)
|
||||
if not is_naver_url(mobile_url):
|
||||
raise ValueError(f"Not a Naver blog URL: {url}")
|
||||
request = urllib.request.Request(mobile_url, headers=DEFAULT_HEADERS)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout, insecure=insecure) as response:
|
||||
return response.read().decode("utf-8", "ignore")
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(
|
||||
f"Naver blog returned HTTP {error.code} for {mobile_url}. "
|
||||
"The post may not exist or access may be restricted."
|
||||
) from error
|
||||
|
||||
|
||||
def extract_title(html: str) -> str:
|
||||
match = TITLE_PATTERN.search(html)
|
||||
if not match:
|
||||
return ""
|
||||
title = unescape(TAG_RE.sub("", match.group(1))).strip()
|
||||
title = re.sub(r"\s*[-:|]?\s*네이버\s*블로그$", "", title).strip()
|
||||
return title
|
||||
|
||||
|
||||
def _extract_div_block(html: str, start_pos: int) -> str:
|
||||
tag_start = html.rfind("<div", 0, start_pos)
|
||||
if tag_start < 0:
|
||||
tag_start = start_pos
|
||||
|
||||
depth = 0
|
||||
pos = tag_start
|
||||
started = False
|
||||
length = len(html)
|
||||
while pos < length:
|
||||
# HTML 주석 건너뛰기
|
||||
if html[pos : pos + 4] == "<!--":
|
||||
end = html.find("-->", pos + 4)
|
||||
pos = end + 3 if end >= 0 else length
|
||||
continue
|
||||
if html[pos : pos + 4] == "<div" and (pos + 4 >= length or html[pos + 4] in (" ", ">", "\t", "\n", "/")):
|
||||
depth += 1
|
||||
started = True
|
||||
elif html[pos : pos + 6] == "</div>":
|
||||
depth -= 1
|
||||
if started and depth == 0:
|
||||
return html[tag_start : pos + 6]
|
||||
pos += 1
|
||||
|
||||
return html[tag_start:]
|
||||
|
||||
|
||||
def extract_content_area(html: str) -> str:
|
||||
cleaned = SCRIPT_STYLE_RE.sub("", html)
|
||||
|
||||
match = re.search(r'class="[^"]*\bse-main-container\b[^"]*"', cleaned)
|
||||
if match:
|
||||
return _extract_div_block(cleaned, match.start())
|
||||
|
||||
for class_name in ("post_ct", "postViewArea", "post-view"):
|
||||
match = re.search(rf'class="[^"]*\b{re.escape(class_name)}\b[^"]*"', cleaned)
|
||||
if match:
|
||||
return _extract_div_block(cleaned, match.start())
|
||||
|
||||
marker = cleaned.find('id="viewTypeSelector"')
|
||||
if marker >= 0:
|
||||
return _extract_div_block(cleaned, marker)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def extract_text(html_fragment: str) -> str:
|
||||
text = BR_RE.sub("\n", html_fragment)
|
||||
text = BLOCK_END_RE.sub("\n", text)
|
||||
text = TAG_RE.sub("", text)
|
||||
text = unescape(text)
|
||||
|
||||
lines = []
|
||||
for line in text.split("\n"):
|
||||
stripped = WHITESPACE_RE.sub(" ", line).strip()
|
||||
if stripped:
|
||||
lines.append(stripped)
|
||||
|
||||
result = "\n".join(lines)
|
||||
result = BLANK_LINES_RE.sub("\n\n", result)
|
||||
return result.strip()
|
||||
|
||||
|
||||
def extract_images(html_fragment: str) -> list[dict]:
|
||||
images: list[dict] = []
|
||||
seen_base: set[str] = set()
|
||||
|
||||
img_tags = re.finditer(r"<img\s[^>]+>", html_fragment, re.IGNORECASE)
|
||||
for img_match in img_tags:
|
||||
img_tag = img_match.group(0)
|
||||
|
||||
lazy_match = IMAGE_LAZY_PATTERN.search(img_tag)
|
||||
src_match = IMAGE_SRC_PATTERN.search(img_tag)
|
||||
url_match = lazy_match or src_match
|
||||
if not url_match:
|
||||
continue
|
||||
|
||||
url = url_match.group(1)
|
||||
|
||||
base_url = re.sub(r"\?type=.*$", "", url)
|
||||
if base_url in seen_base:
|
||||
continue
|
||||
seen_base.add(base_url)
|
||||
|
||||
if "?type=" not in url:
|
||||
url = base_url
|
||||
elif "_blur" in url:
|
||||
url = re.sub(r"\?type=w\d+_blur", "?type=w800", url)
|
||||
|
||||
alt_match = IMAGE_ALT_PATTERN.search(img_tag)
|
||||
alt = unescape(alt_match.group(1)).strip() if alt_match else ""
|
||||
|
||||
images.append({"url": url, "alt": alt})
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def read_blog(url: str, include_images: bool = True, max_length: int = 0, timeout: int = 20, *, insecure: bool = False) -> dict:
|
||||
html = fetch_blog_page(url, timeout=timeout, insecure=insecure)
|
||||
mobile_url = to_mobile_url(url)
|
||||
|
||||
title = extract_title(html)
|
||||
content_area = extract_content_area(html)
|
||||
content = extract_text(content_area)
|
||||
|
||||
if max_length > 0 and len(content) > max_length:
|
||||
content = content[:max_length] + "..."
|
||||
|
||||
result: dict = {
|
||||
"url": mobile_url,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"char_count": len(content),
|
||||
}
|
||||
|
||||
if not content:
|
||||
result["warning"] = "본문 영역을 찾지 못했습니다. 네이버 HTML 구조가 변경되었을 수 있습니다."
|
||||
|
||||
if include_images:
|
||||
result["images"] = extract_images(content_area)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Read a Naver blog post and extract text content and images."
|
||||
)
|
||||
parser.add_argument("url", help="Naver blog post URL (PC or mobile).")
|
||||
parser.add_argument(
|
||||
"--no-images", action="store_true",
|
||||
help="Exclude image URLs from output.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-length", type=int, default=0,
|
||||
help="Maximum content length in characters (0 = unlimited). Default: 0.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=20,
|
||||
help="HTTP request timeout in seconds. Default: 20.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure", action="store_true",
|
||||
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
|
||||
try:
|
||||
result = read_blog(
|
||||
args.url,
|
||||
include_images=not args.no_images,
|
||||
max_length=args.max_length,
|
||||
timeout=args.timeout,
|
||||
insecure=args.insecure,
|
||||
)
|
||||
except (RuntimeError, ValueError) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
192
naver-blog-research/scripts/naver_search.py
Normal file
192
naver-blog-research/scripts/naver_search.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from html import unescape
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _naver_http import TAG_RE, urlopen
|
||||
|
||||
SEARCH_URL = "https://search.naver.com/search.naver"
|
||||
DEFAULT_COUNT = 10
|
||||
MAX_COUNT = 30
|
||||
FIRST_PAGE_START = 1
|
||||
RESULTS_PER_PAGE = 15
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "ko,en-US;q=0.9,en;q=0.8",
|
||||
"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"
|
||||
),
|
||||
}
|
||||
|
||||
BLOG_ANCHOR_PATTERN = re.compile(
|
||||
r'<a[^>]*href="(https?://blog\.naver\.com/([a-zA-Z0-9_]+)/(\d+))"[^>]*>(.*?)</a>',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def strip_html(text: str) -> str:
|
||||
return unescape(TAG_RE.sub("", text)).strip()
|
||||
|
||||
|
||||
def build_search_params(query: str, start: int = FIRST_PAGE_START, sort: str = "sim") -> dict[str, str]:
|
||||
return {
|
||||
"query": query,
|
||||
"ssc": "tab.blog.all",
|
||||
"sm": "tab_jum" if start <= FIRST_PAGE_START else "tab_pge",
|
||||
"start": str(start),
|
||||
"nso": {"sim": "so:r,p:all,a:all", "date": "so:dd,p:all,a:all"}.get(sort, "so:r,p:all,a:all"),
|
||||
}
|
||||
|
||||
|
||||
def fetch_search_page(query: str, start: int = 1, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> str:
|
||||
params = build_search_params(query, start=start, sort=sort)
|
||||
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout, insecure=insecure) as response:
|
||||
return response.read().decode("utf-8", "ignore")
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(
|
||||
f"Naver search returned HTTP {error.code}. "
|
||||
"The request may have been blocked. Retry later or reduce request volume."
|
||||
) from error
|
||||
|
||||
|
||||
def parse_search_results(html: str) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
anchors = BLOG_ANCHOR_PATTERN.findall(html)
|
||||
|
||||
pending: dict[str, dict] = {}
|
||||
|
||||
for full_url, user_id, post_id, inner_html in anchors:
|
||||
if full_url not in pending:
|
||||
pending[full_url] = {
|
||||
"url": full_url,
|
||||
"mobile_url": f"https://m.blog.naver.com/{user_id}/{post_id}",
|
||||
"author": user_id,
|
||||
"title": "",
|
||||
"snippet": "",
|
||||
}
|
||||
|
||||
text = strip_html(inner_html)
|
||||
if not text:
|
||||
continue
|
||||
|
||||
entry = pending[full_url]
|
||||
|
||||
if "headline1" in inner_html or "text-type-headline" in inner_html:
|
||||
if not entry["title"]:
|
||||
entry["title"] = text
|
||||
elif "body1" in inner_html or "text-type-body" in inner_html:
|
||||
if not entry["snippet"]:
|
||||
entry["snippet"] = text
|
||||
else:
|
||||
if not entry["title"]:
|
||||
entry["title"] = text
|
||||
|
||||
for entry in pending.values():
|
||||
results.append(entry)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search(query: str, count: int = DEFAULT_COUNT, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> dict:
|
||||
count = max(1, min(count, MAX_COUNT))
|
||||
all_results: list[dict] = []
|
||||
seen_urls: set[str] = set()
|
||||
start = FIRST_PAGE_START
|
||||
# 네이버 검색이 페이지당 정확히 RESULTS_PER_PAGE개를 반환하지 않을 수 있으므로 여유 페이지 확보
|
||||
max_pages = (count // RESULTS_PER_PAGE) + 3
|
||||
|
||||
for page_num in range(max_pages):
|
||||
if len(all_results) >= count:
|
||||
break
|
||||
|
||||
if page_num > 0:
|
||||
time.sleep(0.5)
|
||||
|
||||
html = fetch_search_page(query, start=start, sort=sort, timeout=timeout, insecure=insecure)
|
||||
page_results = parse_search_results(html)[:RESULTS_PER_PAGE]
|
||||
|
||||
if not page_results:
|
||||
if start == 1:
|
||||
print("[warn] 검색 결과 파싱 실패. 네이버 HTML 구조가 변경되었을 수 있습니다.", file=sys.stderr)
|
||||
break
|
||||
|
||||
new_count = 0
|
||||
for result in page_results:
|
||||
if result["url"] not in seen_urls:
|
||||
seen_urls.add(result["url"])
|
||||
all_results.append(result)
|
||||
new_count += 1
|
||||
if len(all_results) >= count:
|
||||
break
|
||||
|
||||
if new_count == 0:
|
||||
break
|
||||
|
||||
start += RESULTS_PER_PAGE
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"total_results": len(all_results),
|
||||
"results": all_results,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Search Naver blogs and return structured JSON results."
|
||||
)
|
||||
parser.add_argument("query", help="Search query string.")
|
||||
parser.add_argument(
|
||||
"--count", type=int, default=DEFAULT_COUNT,
|
||||
help=f"Number of results to return (max {MAX_COUNT}, default {DEFAULT_COUNT}).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sort", choices=["sim", "date"], default="sim",
|
||||
help="Sort order: sim (relevance) or date (newest first). Default: sim.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=15,
|
||||
help="HTTP request timeout in seconds. Default: 15.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--insecure", action="store_true",
|
||||
help="Skip SSL certificate verification (use only when certificate errors occur).",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
|
||||
try:
|
||||
result = search(
|
||||
args.query,
|
||||
count=args.count,
|
||||
sort=args.sort,
|
||||
timeout=args.timeout,
|
||||
insecure=args.insecure,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
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 scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"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 scripts.test_geeknews_search scripts.test_naver_blog_search && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
"release:npm": "changeset publish"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# blue-ribbon-nearby
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1be3f44: Handle Blue Ribbon `PREMIUM_REQUIRED` nearby responses with a domain error and document the current premium gate on live nearby results.
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "blue-ribbon-nearby",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Official Blue Ribbon Survey nearby restaurant client for asking a user's location and finding nearby ribbon picks",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# cheap-gas-nearby
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1be3f44: Publish the first official Opinet-powered nearby cheapest gas station lookup package and skill docs.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cheap-gas-nearby",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Official Opinet based nearby cheapest gas station lookup for Korean location queries",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
7
packages/hipass-receipt/CHANGELOG.md
Normal file
7
packages/hipass-receipt/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# hipass-receipt
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1be3f44: Publish the first logged-in-session helper package and skill docs for Hi-Pass receipt workflows.
|
||||
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.2.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,29 +1,35 @@
|
|||
# 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/household-waste/info` — 생활쓰레기 배출정보 조회 (`DATA_GO_KR_API_KEY` 서버 주입)
|
||||
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
|
||||
- `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
|
||||
- `DATA_GO_KR_API_KEY` — 프록시 서버 쪽 공공데이터포털 upstream key (`household-waste/info`)
|
||||
- `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`
|
||||
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
|
||||
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
|
||||
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `mfds-drug-safety`)
|
||||
|
||||
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
|
||||
|
||||
|
|
@ -35,8 +41,6 @@ node packages/k-skill-proxy/src/server.js
|
|||
|
||||
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
|
||||
|
||||
> 빠뜨리기 쉬운 값: 생활쓰레기 route는 `DATA_GO_KR_API_KEY`, 학교 검색/급식 route는 `KEDU_INFO_KEY`가 프록시 서버 쪽에 있어야 하며, 누락 시 각 route가 `503 upstream_not_configured`를 반환한다.
|
||||
|
||||
서울 지하철 도착정보 예시:
|
||||
|
||||
```bash
|
||||
|
|
@ -44,6 +48,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
|
||||
|
|
@ -51,20 +63,9 @@ 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` 으로 최신 수위/유량을 조회합니다.
|
||||
나이스 학교 검색·급식 식단 예시 (`KEDU_INFO_KEY` 필요). 급식은 교육청 코드(`ATPT_OFCDC_SC_CODE`)와 학교 코드(`SD_SCHUL_CODE`)가 필요하므로 보통 아래 순서로 호출한다.
|
||||
|
||||
생활쓰레기 배출정보 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
프록시는 `serviceKey`를 `DATA_GO_KR_API_KEY`에서만 주입하고 `returnType=json`을 강제합니다. `pageNo`는 정확히 `1`만 허용하고 `numOfRows`는 정확히 `100`만 허용합니다.
|
||||
|
||||
학교 검색 예시:
|
||||
학교 검색:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-search' \
|
||||
|
|
@ -72,7 +73,7 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-search' \
|
|||
--data-urlencode 'schoolName=미래초등학교'
|
||||
```
|
||||
|
||||
학교 급식 예시:
|
||||
급식 식단:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-meal' \
|
||||
|
|
@ -81,6 +82,25 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-meal' \
|
|||
--data-urlencode 'mealDate=20260410'
|
||||
```
|
||||
|
||||
생활쓰레기 배출정보 예시 (`DATA_GO_KR_API_KEY` 필요). `pageNo`·`numOfRows`는 반드시 `1`·`100`:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
|
||||
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
|
||||
--data-urlencode 'pageNo=1' \
|
||||
--data-urlencode 'numOfRows=100'
|
||||
```
|
||||
|
||||
한국 주식 검색 예시:
|
||||
|
||||
```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,14 +3,20 @@ 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";
|
||||
|
||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||
|
|
@ -43,37 +49,81 @@ function trimOrNull(value) {
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
function trimSingleQueryValueOrNull(value, fieldName) {
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error(`${fieldName} must be provided exactly once.`);
|
||||
}
|
||||
return trimOrNull(value);
|
||||
function padNumber(value, length) {
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
function trimSingleAliasedQueryValueOrNull(query, aliases, fieldName) {
|
||||
const providedAliases = aliases.filter((alias) => Object.hasOwn(query, alias));
|
||||
if (providedAliases.length > 1) {
|
||||
throw new Error(`${fieldName} must be provided exactly once.`);
|
||||
}
|
||||
|
||||
if (providedAliases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimSingleQueryValueOrNull(query[providedAliases[0]], fieldName);
|
||||
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 requireFixedQueryInteger(query, aliases, fieldName, expectedValue) {
|
||||
const rawValue = trimSingleAliasedQueryValueOrNull(query, aliases, fieldName);
|
||||
if (rawValue === null) {
|
||||
throw new Error(`${fieldName} is required and must be exactly ${expectedValue}.`);
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(rawValue) || Number.parseInt(rawValue, 10) !== expectedValue) {
|
||||
throw new Error(`${fieldName} must be exactly ${expectedValue}.`);
|
||||
}
|
||||
return {
|
||||
baseDate: formatKstDate(new Date(now.getTime() - (24 * 60 * 60 * 1000))),
|
||||
baseTime: KMA_FORECAST_BASE_TIMES[KMA_FORECAST_BASE_TIMES.length - 1]
|
||||
};
|
||||
}
|
||||
|
||||
return String(expectedValue);
|
||||
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) {
|
||||
|
|
@ -82,12 +132,14 @@ function buildConfig(env = process.env) {
|
|||
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)
|
||||
|
|
@ -128,7 +180,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);
|
||||
|
||||
|
|
@ -188,6 +240,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);
|
||||
|
|
@ -364,22 +486,6 @@ function normalizeRegionCodeQuery(query) {
|
|||
return { q };
|
||||
}
|
||||
|
||||
function normalizeHouseholdWasteInfoQuery(query) {
|
||||
const sggNm = trimSingleQueryValueOrNull(query["cond[SGG_NM::LIKE]"], "cond[SGG_NM::LIKE]");
|
||||
if (!sggNm) {
|
||||
throw new Error("cond[SGG_NM::LIKE] is required");
|
||||
}
|
||||
|
||||
const pageNo = requireFixedQueryInteger(query, ["pageNo", "page_no"], "pageNo", 1);
|
||||
const numOfRows = requireFixedQueryInteger(query, ["numOfRows", "num_of_rows"], "numOfRows", 100);
|
||||
|
||||
return {
|
||||
sggNm,
|
||||
pageNo,
|
||||
numOfRows
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHanRiverWaterLevelQuery(query) {
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
|
||||
const stationCode = trimOrNull(query.stationCode ?? query.station_code ?? query.wlobscd);
|
||||
|
|
@ -394,6 +500,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;
|
||||
|
|
@ -481,6 +649,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 {
|
||||
|
|
@ -642,7 +853,53 @@ async function proxyNeisSchoolInfoRequest({
|
|||
}
|
||||
|
||||
|
||||
function buildServer({ env = process.env, provider = null } = {}) {
|
||||
|
||||
function validateHouseholdWastePaginationQuery(query) {
|
||||
const HOUSEHOLD_WASTE_PAGINATION_RULE =
|
||||
"Household waste info requires pageNo=1 and numOfRows=100 (page_no and num_of_rows accepted). Other values or non-digit strings return 400.";
|
||||
|
||||
const rawPage = query.pageNo ?? query.page_no;
|
||||
const rawNum = query.numOfRows ?? query.num_of_rows;
|
||||
const pageProvided =
|
||||
rawPage !== undefined && rawPage !== null && String(rawPage).trim() !== "";
|
||||
const numProvided =
|
||||
rawNum !== undefined && rawNum !== null && String(rawNum).trim() !== "";
|
||||
|
||||
if (!pageProvided || !numProvided) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
const parseDigitsOnlyUInt = (raw, label) => {
|
||||
const s = String(raw).trim();
|
||||
if (!/^\d+$/.test(s)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid ${label} for household waste info: use digits only; pageNo must be 1 and numOfRows must be 100.`
|
||||
};
|
||||
}
|
||||
return { ok: true, value: Number.parseInt(s, 10) };
|
||||
};
|
||||
|
||||
const pageParsed = parseDigitsOnlyUInt(rawPage, "pageNo");
|
||||
if (!pageParsed.ok) {
|
||||
return pageParsed;
|
||||
}
|
||||
if (pageParsed.value !== 1) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
const numParsed = parseDigitsOnlyUInt(rawNum, "numOfRows");
|
||||
if (!numParsed.ok) {
|
||||
return numParsed;
|
||||
}
|
||||
if (numParsed.value !== 100) {
|
||||
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function buildServer({ env = process.env, provider = null, now = () => new Date() } = {}) {
|
||||
const config = buildConfig(env);
|
||||
const cache = createMemoryCache();
|
||||
const rateLimit = buildRateLimiter(config);
|
||||
|
|
@ -673,12 +930,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)
|
||||
neisSchoolMealConfigured: Boolean(config.keduInfoKey),
|
||||
krxConfigured: Boolean(config.krxApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -820,6 +1079,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;
|
||||
|
||||
|
|
@ -1235,21 +1555,32 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
});
|
||||
|
||||
app.get("/v1/household-waste/info", async (request, reply) => {
|
||||
let normalized;
|
||||
const query = request.query || {};
|
||||
const sggNm = query["cond[SGG_NM::LIKE]"];
|
||||
|
||||
try {
|
||||
normalized = normalizeHouseholdWasteInfoQuery(request.query || {});
|
||||
} catch (error) {
|
||||
if (!sggNm || !sggNm.trim()) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
message: "cond[SGG_NM::LIKE] is required"
|
||||
};
|
||||
}
|
||||
|
||||
const paginationCheck = validateHouseholdWastePaginationQuery(query);
|
||||
if (!paginationCheck.ok) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: paginationCheck.message
|
||||
};
|
||||
}
|
||||
|
||||
const pageNo = "1";
|
||||
const numOfRows = "100";
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "household-waste-info",
|
||||
...normalized
|
||||
sggNm: sggNm.trim()
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
|
|
@ -1273,10 +1604,10 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
|
||||
const url = new URL("https://apis.data.go.kr/1741000/household_waste_info/info");
|
||||
url.searchParams.set("serviceKey", config.molitApiKey);
|
||||
url.searchParams.set("pageNo", normalized.pageNo);
|
||||
url.searchParams.set("numOfRows", normalized.numOfRows);
|
||||
url.searchParams.set("pageNo", pageNo);
|
||||
url.searchParams.set("numOfRows", numOfRows);
|
||||
url.searchParams.set("returnType", "json");
|
||||
url.searchParams.set("cond[SGG_NM::LIKE]", normalized.sggNm);
|
||||
url.searchParams.set("cond[SGG_NM::LIKE]", sggNm.trim());
|
||||
|
||||
let upstreamData;
|
||||
try {
|
||||
|
|
@ -1301,11 +1632,7 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
|
||||
const payload = {
|
||||
...upstreamData,
|
||||
query: {
|
||||
sgg_nm: normalized.sggNm,
|
||||
page_no: normalized.pageNo,
|
||||
num_of_rows: normalized.numOfRows
|
||||
},
|
||||
query: { sgg_nm: sggNm.trim(), page_no: pageNo, num_of_rows: numOfRows },
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
|
|
@ -1316,6 +1643,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;
|
||||
|
|
@ -1367,13 +1785,22 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
const upstream = await proxyNeisSchoolInfoRequest({
|
||||
apiKey: config.keduInfoKey,
|
||||
atptOfcdcScCode: normalized.atptOfcdcScCode,
|
||||
schulNm: normalized.schulNm,
|
||||
pIndex: normalized.pIndex,
|
||||
pSize: normalized.pSize
|
||||
});
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await proxyNeisSchoolInfoRequest({
|
||||
apiKey: config.keduInfoKey,
|
||||
atptOfcdcScCode: normalized.atptOfcdcScCode,
|
||||
schulNm: normalized.schulNm,
|
||||
pIndex: normalized.pIndex,
|
||||
pSize: normalized.pSize
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
|
@ -1467,15 +1894,24 @@ 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
|
||||
});
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await proxyNeisSchoolMealRequest({
|
||||
apiKey: config.keduInfoKey,
|
||||
atptOfcdcScCode: normalized.atptOfcdcScCode,
|
||||
sdSchulCode: normalized.sdSchulCode,
|
||||
mlsvYmd: normalized.mlsvYmd,
|
||||
mmealScCode: normalized.mmealScCode,
|
||||
pIndex: normalized.pIndex,
|
||||
pSize: normalized.pSize
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
return {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
|
@ -1520,6 +1956,192 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/korean-stock/base-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-base-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 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
|
||||
};
|
||||
}
|
||||
|
||||
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/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.setErrorHandler((error, request, reply) => {
|
||||
request.log.error(error);
|
||||
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
|
|
@ -1563,14 +2185,17 @@ if (require.main === module) {
|
|||
module.exports = {
|
||||
buildConfig,
|
||||
buildServer,
|
||||
convertLatLonToKmaGrid,
|
||||
normalizeBlueRibbonNearbyQuery,
|
||||
normalizeFineDustQuery,
|
||||
normalizeHanRiverWaterLevelQuery,
|
||||
normalizeKmaForecastQuery,
|
||||
normalizeKoreanStockLookupQuery,
|
||||
normalizeKoreanStockSearchQuery,
|
||||
normalizeOpinetAroundQuery,
|
||||
normalizeOpinetDetailQuery,
|
||||
normalizeNeisSchoolMealQuery,
|
||||
normalizeNeisSchoolSearchQuery,
|
||||
normalizeHouseholdWasteInfoQuery,
|
||||
normalizeRealEstateQuery,
|
||||
normalizeRegionCodeQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
|
|
@ -1578,7 +2203,9 @@ module.exports = {
|
|||
proxyHrfcoWaterLevelRequest,
|
||||
proxyNeisSchoolMealRequest,
|
||||
proxyNeisSchoolInfoRequest,
|
||||
proxyKmaWeatherRequest,
|
||||
proxyOpinetRequest,
|
||||
proxySeoulSubwayRequest,
|
||||
resolveLatestKmaForecastBase,
|
||||
startServer
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,11 @@
|
|||
# lck-analytics
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1be3f44: Add the first LCK analytics package and skill pack adapted from jerjangmin's original upstream implementation.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "lck-analytics",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "LCK match analytics and insights powered by Riot LoL Esports data",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
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"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
# used-car-price-search
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 1be3f44: Publish the first reusable used-car-price-search package with the SK direct inventory parser and skill docs.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "used-car-price-search",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "SK렌터카 다이렉트 타고BUY 기반 중고차 가격 조회 client",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
45
scripts/fixtures/geeknews-feed.xml
Normal file
45
scripts/fixtures/geeknews-feed.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed xmlns='http://www.w3.org/2005/Atom'>
|
||||
<title>GeekNews - 개발/기술/스타트업 뉴스 서비스</title>
|
||||
<subtitle>개발 뉴스, 기술 관련 새소식, 스타트업 정보와 노하우</subtitle>
|
||||
<link ref='alternate' type='text/html' href='https://news.hada.io' />
|
||||
<link ref='self' type='application/atom+xml' href='https://news.hada.io/rss/news' />
|
||||
<id>https://news.hada.io/rss/news</id>
|
||||
<updated>2026-04-12T22:53:56+09:00</updated>
|
||||
<entry>
|
||||
<title><![CDATA[Ask GN: 기억이 안나는 웹사이트를 찾고 있습니다.]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28441' />
|
||||
<id>https://news.hada.io/topic?id=28441</id>
|
||||
<updated>2026-04-12T22:53:56+09:00</updated>
|
||||
<published>2026-04-12T22:53:56+09:00</published>
|
||||
<author>
|
||||
<name>princox</name>
|
||||
<uri>https://news.hada.io/user/princox</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<p>예전에 아내와 함께 인상깊게 봤던 웹 사이트가 있었는데 기억이 안납니다.</p><p>교육용 시각화 사이트였습니다.</p>]]></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[AI 에이전트 벤치마크를 무너뜨린 방법과 그 다음 단계]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28440' />
|
||||
<id>https://news.hada.io/topic?id=28440</id>
|
||||
<updated>2026-04-12T21:32:54+09:00</updated>
|
||||
<published>2026-04-12T21:32:54+09:00</published>
|
||||
<author>
|
||||
<name>neo</name>
|
||||
<uri>https://news.hada.io/user/neo</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<ul><li>주요 AI agent benchmark 8종이 실제 문제 해결 없이도 최고 점수를 얻을 수 있는 구조적 취약점을 가진 것으로 드러남</li></ul>]]></content>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Show GN: [GN] 비개발자 + Claude로 프로덕션 운영 238일 — 무엇이 됐고 무엇이 안 됐나?]]></title>
|
||||
<link rel='alternate' type='text/html' href='https://news.hada.io/topic?id=28439' />
|
||||
<id>https://news.hada.io/topic?id=28439</id>
|
||||
<updated>2026-04-12T20:53:05+09:00</updated>
|
||||
<published>2026-04-12T20:53:05+09:00</published>
|
||||
<author>
|
||||
<name>workdriver</name>
|
||||
<uri>https://news.hada.io/user/workdriver</uri>
|
||||
</author>
|
||||
<content type='html' xml:lang='ko'><![CDATA[<p>코드를 한 줄도 칠 줄 모르는 상태에서 2025년 8월 16일 Claude와 함께 첫 커밋을 찍었습니다.</p>]]></content>
|
||||
</entry>
|
||||
</feed>
|
||||
296
scripts/geeknews_search.py
Executable file
296
scripts/geeknews_search.py
Executable file
|
|
@ -0,0 +1,296 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
from dataclasses import asdict, dataclass
|
||||
from html import unescape
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
GEEKNEWS_FEED_URL = "https://feeds.feedburner.com/geeknews-feed"
|
||||
|
||||
|
||||
class _TextExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.parts: list[str] = []
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.parts.append(data)
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(part.strip() for part in self.parts if part.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsItem:
|
||||
id: str
|
||||
title: str
|
||||
link: str
|
||||
published: str | None
|
||||
updated: str | None
|
||||
author_name: str | None
|
||||
author_url: str | None
|
||||
summary: str
|
||||
content_html: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GeekNewsFeed:
|
||||
title: str
|
||||
source_id: str | None
|
||||
updated: str | None
|
||||
home_url: str | None
|
||||
feed_url: str | None
|
||||
category: str | None
|
||||
items: list[GeekNewsItem]
|
||||
|
||||
def source_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"title": self.title,
|
||||
"id": self.source_id,
|
||||
"updated": self.updated,
|
||||
"home_url": self.home_url,
|
||||
"feed_url": self.feed_url,
|
||||
"category": self.category,
|
||||
}
|
||||
|
||||
|
||||
def _strip_cdata(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
stripped = value.strip()
|
||||
if stripped.startswith("<![CDATA[") and stripped.endswith("]]>"):
|
||||
return stripped[9:-3]
|
||||
return stripped
|
||||
|
||||
|
||||
def _collapse_whitespace(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def _clean_xml_text(value: str | None) -> str:
|
||||
return _collapse_whitespace(unescape(_strip_cdata(value)))
|
||||
|
||||
|
||||
def _html_to_text(html: str) -> str:
|
||||
parser = _TextExtractor()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return _collapse_whitespace(unescape(parser.text()))
|
||||
|
||||
|
||||
def _first_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _clean_xml_text(match.group(1))
|
||||
|
||||
|
||||
def _first_raw_tag(block: str, tag: str) -> str | None:
|
||||
match = re.search(rf"<{tag}\b[^>]*>(.*?)</{tag}>", block, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
return _strip_cdata(match.group(1)).strip()
|
||||
|
||||
|
||||
def _first_link_href(block: str) -> str | None:
|
||||
patterns = (
|
||||
r"<link\b[^>]*rel=['\"]alternate['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
r"<link\b[^>]*href=['\"]([^'\"]+)['\"]",
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, block)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return None
|
||||
|
||||
|
||||
def _link_href(block: str, *, rel: str | None = None) -> str | None:
|
||||
if rel:
|
||||
match = re.search(
|
||||
rf"<link\b[^>]*(?:rel|ref)=['\"]{re.escape(rel)}['\"][^>]*href=['\"]([^'\"]+)['\"]",
|
||||
block,
|
||||
)
|
||||
if match:
|
||||
return unescape(match.group(1).strip())
|
||||
return _first_link_href(block)
|
||||
|
||||
|
||||
def _feed_prefix(xml_text: str) -> str:
|
||||
if "<entry" not in xml_text:
|
||||
return xml_text
|
||||
return xml_text.split("<entry", 1)[0]
|
||||
|
||||
|
||||
def _entry_blocks(xml_text: str) -> list[str]:
|
||||
return re.findall(r"<entry\b[^>]*>(.*?)</entry>", xml_text, re.DOTALL)
|
||||
|
||||
|
||||
def _validate_limit(limit: int) -> int:
|
||||
if limit <= 0:
|
||||
raise ValueError("limit must be positive")
|
||||
return limit
|
||||
|
||||
|
||||
def load_feed(xml_text: str) -> GeekNewsFeed:
|
||||
prefix = _feed_prefix(xml_text)
|
||||
items = []
|
||||
for entry in _entry_blocks(xml_text):
|
||||
author_block_match = re.search(r"<author\b[^>]*>(.*?)</author>", entry, re.DOTALL)
|
||||
author_block = author_block_match.group(1) if author_block_match else ""
|
||||
content_html = (_first_raw_tag(entry, "content") or "").strip()
|
||||
items.append(
|
||||
GeekNewsItem(
|
||||
id=_first_tag(entry, "id") or "",
|
||||
title=_first_tag(entry, "title") or "",
|
||||
link=_first_link_href(entry) or (_first_tag(entry, "id") or ""),
|
||||
published=_first_tag(entry, "published") or _first_tag(entry, "updated"),
|
||||
updated=_first_tag(entry, "updated"),
|
||||
author_name=_first_tag(author_block, "name"),
|
||||
author_url=_first_tag(author_block, "uri"),
|
||||
summary=_html_to_text(content_html),
|
||||
content_html=content_html,
|
||||
)
|
||||
)
|
||||
|
||||
category_match = re.search(r"<category\b[^>]*term=['\"]([^'\"]+)['\"]", prefix)
|
||||
return GeekNewsFeed(
|
||||
title=_first_tag(prefix, "title") or "GeekNews",
|
||||
source_id=_first_tag(prefix, "id"),
|
||||
updated=_first_tag(prefix, "updated"),
|
||||
home_url=_link_href(prefix, rel="alternate"),
|
||||
feed_url=_link_href(prefix, rel="self") or _first_tag(prefix, "id"),
|
||||
category=category_match.group(1) if category_match else None,
|
||||
items=items,
|
||||
)
|
||||
|
||||
|
||||
def list_items(feed: GeekNewsFeed, limit: int = 10) -> list[GeekNewsItem]:
|
||||
return feed.items[:_validate_limit(limit)]
|
||||
|
||||
|
||||
def search_items(feed: GeekNewsFeed, query: str, limit: int = 10) -> list[GeekNewsItem]:
|
||||
if not query.strip():
|
||||
raise ValueError("query is required")
|
||||
limit = _validate_limit(limit)
|
||||
needle = query.casefold()
|
||||
matches = []
|
||||
for item in feed.items:
|
||||
haystack = "\n".join(
|
||||
part
|
||||
for part in (
|
||||
item.title,
|
||||
item.summary,
|
||||
item.author_name or "",
|
||||
item.author_url or "",
|
||||
item.id,
|
||||
item.link,
|
||||
)
|
||||
if part
|
||||
).casefold()
|
||||
if needle in haystack:
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
return matches
|
||||
|
||||
|
||||
def get_item_detail(feed: GeekNewsFeed, lookup: str) -> GeekNewsItem:
|
||||
normalized_lookup = lookup.strip().casefold()
|
||||
if not normalized_lookup:
|
||||
raise ValueError("lookup is required")
|
||||
for item in feed.items:
|
||||
candidates = [item.id, item.link, item.title]
|
||||
lowered = [candidate.casefold() for candidate in candidates if candidate]
|
||||
if normalized_lookup in lowered or any(normalized_lookup in candidate for candidate in lowered):
|
||||
return item
|
||||
raise LookupError(f"No GeekNews entry matched: {lookup}")
|
||||
|
||||
|
||||
def _serialize_items(items: list[GeekNewsItem]) -> list[dict[str, object]]:
|
||||
return [item.to_dict() for item in items]
|
||||
|
||||
|
||||
def build_list_payload(feed: GeekNewsFeed, limit: int = 10) -> dict[str, object]:
|
||||
items = list_items(feed, limit=limit)
|
||||
return {"source": feed.source_dict(), "count": len(items), "items": _serialize_items(items)}
|
||||
|
||||
|
||||
def build_search_payload(feed: GeekNewsFeed, query: str, limit: int = 10) -> dict[str, object]:
|
||||
items = search_items(feed, query=query, limit=limit)
|
||||
return {
|
||||
"source": feed.source_dict(),
|
||||
"query": query,
|
||||
"count": len(items),
|
||||
"items": _serialize_items(items),
|
||||
}
|
||||
|
||||
|
||||
def build_detail_payload(feed: GeekNewsFeed, lookup: str) -> dict[str, object]:
|
||||
item = get_item_detail(feed, lookup)
|
||||
return {"source": feed.source_dict(), "item": item.to_dict()}
|
||||
|
||||
|
||||
def fetch_feed(url: str = GEEKNEWS_FEED_URL, timeout: int = 20) -> str:
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "k-skill-geeknews/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(charset, errors="replace")
|
||||
|
||||
|
||||
def _add_feed_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument("--feed-url", default=GEEKNEWS_FEED_URL, help="기본값: GeekNews public feed URL")
|
||||
parser.add_argument("--feed-file", help="테스트/오프라인 검증용 로컬 Atom XML 파일")
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Read GeekNews entries from the public RSS/Atom feed.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="최신 GeekNews 항목 목록")
|
||||
_add_feed_source_args(list_parser)
|
||||
list_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="제목/요약/작성자 기준 검색")
|
||||
_add_feed_source_args(search_parser)
|
||||
search_parser.add_argument("--query", required=True)
|
||||
search_parser.add_argument("--limit", type=int, default=10)
|
||||
|
||||
detail_parser = subparsers.add_parser("detail", help="항목 상세 확인")
|
||||
_add_feed_source_args(detail_parser)
|
||||
detail_parser.add_argument("--id", required=True, help="entry id/link/topic id 일부")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _load_feed_text(args: argparse.Namespace) -> str:
|
||||
if args.feed_file:
|
||||
return Path(args.feed_file).read_text(encoding="utf-8")
|
||||
return fetch_feed(url=args.feed_url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = parse_args(argv)
|
||||
feed = load_feed(_load_feed_text(args))
|
||||
|
||||
if args.command == "list":
|
||||
payload = build_list_payload(feed, limit=args.limit)
|
||||
elif args.command == "search":
|
||||
payload = build_search_payload(feed, query=args.query, limit=args.limit)
|
||||
else:
|
||||
payload = build_detail_payload(feed, lookup=args.id)
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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())
|
||||
|
|
@ -143,16 +143,6 @@ test("root npm test script includes the skill docs regression suite", () => {
|
|||
assert.match(packageJson.scripts.test, /node --test scripts\/skill-docs\.test\.js/);
|
||||
});
|
||||
|
||||
test("validate-skills ignores hidden metadata directories", () => {
|
||||
const result = childProcess.spawnSync("bash", ["scripts/validate-skills.sh"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /skill layout looks valid/);
|
||||
});
|
||||
|
||||
test("README advertises OpenClaw among the supported coding agents", () => {
|
||||
const readme = read("README.md");
|
||||
|
||||
|
|
@ -218,101 +208,6 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
|
|||
assert.match(install, /--skill kakaotalk-mac/);
|
||||
});
|
||||
|
||||
test("proxy docs keep KEDU_INFO_KEY server-only and document household-waste env requirements", () => {
|
||||
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const proxyFeatureDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
|
||||
assert.doesNotMatch(secretsExample, /^KEDU_INFO_KEY=/m);
|
||||
|
||||
assert.match(proxyReadme, /GET \/v1\/household-waste\/info/);
|
||||
assert.match(proxyReadme, /DATA_GO_KR_API_KEY/);
|
||||
|
||||
assert.match(proxyFeatureDoc, /GET \/v1\/household-waste\/info/);
|
||||
assert.match(proxyFeatureDoc, /DATA_GO_KR_API_KEY/);
|
||||
});
|
||||
|
||||
|
||||
test("household-waste and proxy docs lock the narrowed household-waste curl contract", () => {
|
||||
const skill = read(path.join("household-waste-info", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "household-waste-info.md"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const proxyFeatureDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc, proxyReadme, proxyFeatureDoc]) {
|
||||
assert.match(doc, /pageNo=1/);
|
||||
assert.match(doc, /numOfRows=100/);
|
||||
assert.match(doc, /pageNo[^\n]*정확히 [`']?1[`']?만 허용/);
|
||||
assert.match(doc, /numOfRows[^\n]*정확히 [`']?100[`']?만 허용/);
|
||||
}
|
||||
});
|
||||
|
||||
test("proxy package README documents both NEIS curl steps", () => {
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
|
||||
assert.match(proxyReadme, /\/v1\/neis\/school-search/);
|
||||
assert.match(proxyReadme, /educationOffice=서울특별시교육청/);
|
||||
assert.match(proxyReadme, /schoolName=미래초등학교/);
|
||||
assert.match(proxyReadme, /\/v1\/neis\/school-meal/);
|
||||
assert.match(proxyReadme, /educationOfficeCode=B10/);
|
||||
assert.match(proxyReadme, /schoolCode=7010123/);
|
||||
assert.match(proxyReadme, /mealDate=20260410/);
|
||||
});
|
||||
|
||||
test("setup guide lists hosted proxy skill coverage including household waste and school lunch", () => {
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 부동산 실거래가, 학교 급식 식단은 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다\./,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/\| 생활쓰레기 배출정보 조회 \| 사용자 시크릿 불필요 \(프록시에 `DATA_GO_KR_API_KEY`가 설정된 hosted\/self-host 사용\) \|/,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 그대로 쓴다\./,
|
||||
);
|
||||
assert.match(
|
||||
setup,
|
||||
/\| 학교 급식 식단 조회 \| 사용자 시크릿 불필요 \(프록시에 `KEDU_INFO_KEY`가 설정된 hosted\/self-host 사용\) \|/,
|
||||
);
|
||||
assert.match(setup, /\[생활쓰레기 배출정보 조회 가이드\]\(features\/household-waste-info\.md\)/);
|
||||
assert.match(setup, /\[학교 급식 식단 조회 가이드\]\(features\/k-schoollunch-menu\.md\)/);
|
||||
});
|
||||
|
||||
test("k-skill setup skill keeps hosted proxy guidance aligned for household waste and school lunch", () => {
|
||||
const skill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
|
||||
assert.match(
|
||||
skill,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 그대로 쓴다\./,
|
||||
);
|
||||
assert.match(
|
||||
skill,
|
||||
/생활쓰레기 배출정보 조회: 사용자 시크릿 불필요 \(`serviceKey`\(`DATA_GO_KR_API_KEY`\)는 proxy 서버 주입\)/,
|
||||
);
|
||||
assert.match(
|
||||
skill,
|
||||
/학교 급식 식단 조회: 사용자 시크릿 불필요 \(`KEDU_INFO_KEY`는 proxy 서버 주입\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("security and install docs keep school lunch on the hosted proxy / no-user-key path", () => {
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
||||
assert.match(
|
||||
security,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보, 학교 급식 식단은 이 값이 없으면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)를 사용한다\./,
|
||||
);
|
||||
assert.match(
|
||||
install,
|
||||
/`k-schoollunch-menu` 는 별도 설치 없이 `k-skill-proxy`의 `\/v1\/neis\/school-search`, `\/v1\/neis\/school-meal` 라우트를 호출하고, `KEDU_INFO_KEY`는 proxy 서버에서만 나이스 Open API `KEY`로 주입한다\. 사용자 쪽 `KEDU_INFO_KEY` 가 불필요하다\./,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the used-car-price-search skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
@ -326,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/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -387,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"));
|
||||
|
|
@ -455,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");
|
||||
|
||||
|
|
@ -538,6 +513,61 @@ test("ktx-booking helper python regression tests pass", () => {
|
|||
);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("repository docs advertise the geeknews-search 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", "geeknews-search.md");
|
||||
const skillPath = path.join(repoRoot, "geeknews-search", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/geeknews-search.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected geeknews-search/SKILL.md to exist");
|
||||
assert.match(readme, /\| 긱뉴스 조회 \|/);
|
||||
assert.match(readme, /\[긱뉴스 조회 가이드\]\(docs\/features\/geeknews-search\.md\)/);
|
||||
assert.match(install, /--skill geeknews-search/);
|
||||
});
|
||||
|
||||
test("geeknews-search docs lock the RSS-first list-search-detail workflow", () => {
|
||||
const skill = read(path.join("geeknews-search", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "geeknews-search.md"));
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /feeds\.feedburner\.com\/geeknews-feed/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py list/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py search/);
|
||||
assert.match(doc, /python3 scripts\/geeknews_search\.py detail/);
|
||||
assert.match(doc, /RSS-first|RSS first|RSS 피드/);
|
||||
assert.match(doc, /read-only|읽기 전용/);
|
||||
}
|
||||
});
|
||||
|
||||
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"));
|
||||
|
|
@ -553,40 +583,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", () => {
|
||||
|
|
@ -854,6 +885,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"));
|
||||
|
|
@ -924,6 +1003,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");
|
||||
|
|
@ -957,6 +1100,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/);
|
||||
|
|
@ -1028,8 +1172,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/);
|
||||
|
|
@ -1222,6 +1367,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");
|
||||
|
||||
|
|
@ -1245,6 +1410,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"));
|
||||
|
||||
|
|
@ -1257,6 +1448,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"));
|
||||
|
||||
|
|
@ -1268,10 +1469,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/);
|
||||
});
|
||||
|
||||
|
|
@ -1429,6 +1660,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"));
|
||||
|
|
@ -1493,6 +1797,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"));
|
||||
|
|
@ -1505,6 +1878,101 @@ 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,
|
||||
/--skill k-schoollunch-menu \\\n --skill korean-character-count/,
|
||||
"docs/install.md selective-install block should keep k-schoollunch-menu and korean-character-count in the same continued shell command",
|
||||
);
|
||||
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"));
|
||||
|
|
@ -1599,3 +2067,124 @@ 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/);
|
||||
});
|
||||
|
||||
test("docs/setup.md and k-skill-setup document hosted household waste proxy flow", () => {
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
|
||||
assert.match(
|
||||
setup,
|
||||
/한국 주식 정보 조회, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 기본 hosted proxy를 쓰므로/,
|
||||
"setup.md intro should list household waste among hosted-proxy features with no user-side key",
|
||||
);
|
||||
assert.match(setup, /DATA_GO_KR_API_KEY.*서버에 설정/);
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
|
||||
"setup.md should list fine dust, Han River, gas, household waste, and school lunch when KSKILL_PROXY_BASE_URL is unset",
|
||||
);
|
||||
assert.match(
|
||||
setupSkill,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL`/,
|
||||
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance",
|
||||
);
|
||||
|
||||
assert.match(setup, /\| 생활쓰레기 배출정보 조회 \|/);
|
||||
assert.match(setup, /DATA_GO_KR_API_KEY/);
|
||||
assert.match(setup, /pageNo=1.*numOfRows=100|numOfRows=100.*pageNo=1/);
|
||||
assert.match(setup, /\[생활쓰레기 배출정보 조회 가이드\]\(features\/household-waste-info\.md\)/);
|
||||
|
||||
assert.match(setupSkill, /\/v1\/household-waste\/info/);
|
||||
assert.match(setupSkill, /DATA_GO_KR_API_KEY/);
|
||||
assert.match(setupSkill, /생활쓰레기 배출정보 조회: 사용자 시크릿 불필요/);
|
||||
});
|
||||
|
||||
test("docs/setup.md and k-skill-setup document hosted school lunch proxy flow", () => {
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
||||
assert.match(setup, /학교 급식 식단 조회는 기본 hosted proxy/);
|
||||
assert.match(setup, /KEDU_INFO_KEY.*서버에 설정/);
|
||||
assert.match(
|
||||
setup,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path\(`k-skill-proxy\.nomadamas\.org`\)/,
|
||||
"setup.md should list fine dust, Han River, gas, household waste, and school lunch when KSKILL_PROXY_BASE_URL is unset",
|
||||
);
|
||||
assert.match(
|
||||
setupSkill,
|
||||
/미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회는 `KSKILL_PROXY_BASE_URL`/,
|
||||
"k-skill-setup SKILL should mirror setup.md hosted-proxy unset-base-URL guidance",
|
||||
);
|
||||
|
||||
assert.match(setup, /\| 학교 급식 식단 조회 \|/);
|
||||
assert.match(setup, /KEDU_INFO_KEY/);
|
||||
assert.match(setup, /\[학교 급식 식단 조회 가이드\]\(features\/k-schoollunch-menu\.md\)/);
|
||||
|
||||
assert.match(setupSkill, /\/v1\/neis\/school-search/);
|
||||
assert.match(setupSkill, /\/v1\/neis\/school-meal/);
|
||||
assert.match(setupSkill, /KEDU_INFO_KEY/);
|
||||
assert.match(setupSkill, /학교 급식 식단 조회: 사용자 시크릿 불필요/);
|
||||
|
||||
assert.doesNotMatch(
|
||||
examplesSecrets,
|
||||
/^KEDU_INFO_KEY=/m,
|
||||
"client secrets example must not encourage KEDU_INFO_KEY (proxy server only)",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
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())
|
||||
128
scripts/test_geeknews_search.py
Normal file
128
scripts/test_geeknews_search.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from scripts.geeknews_search import (
|
||||
GeekNewsFeed,
|
||||
build_detail_payload,
|
||||
build_list_payload,
|
||||
build_search_payload,
|
||||
get_item_detail,
|
||||
list_items,
|
||||
load_feed,
|
||||
main,
|
||||
search_items,
|
||||
)
|
||||
|
||||
FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "geeknews-feed.xml"
|
||||
|
||||
|
||||
class GeekNewsFeedParseTest(unittest.TestCase):
|
||||
def test_load_feed_parses_atom_entries_into_normalized_items(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertIsInstance(feed, GeekNewsFeed)
|
||||
self.assertEqual(feed.title, "GeekNews - 개발/기술/스타트업 뉴스 서비스")
|
||||
self.assertEqual(feed.updated, "2026-04-12T22:53:56+09:00")
|
||||
self.assertEqual(feed.home_url, "https://news.hada.io")
|
||||
self.assertEqual(feed.feed_url, "https://news.hada.io/rss/news")
|
||||
self.assertEqual(len(feed.items), 3)
|
||||
|
||||
first = feed.items[0]
|
||||
self.assertEqual(first.title, "Ask GN: 기억이 안나는 웹사이트를 찾고 있습니다.")
|
||||
self.assertEqual(first.link, "https://news.hada.io/topic?id=28441")
|
||||
self.assertEqual(first.id, "https://news.hada.io/topic?id=28441")
|
||||
self.assertEqual(first.author_name, "princox")
|
||||
self.assertEqual(first.author_url, "https://news.hada.io/user/princox")
|
||||
self.assertEqual(first.published, "2026-04-12T22:53:56+09:00")
|
||||
self.assertIn("시각화 사이트", first.summary)
|
||||
self.assertNotIn("<p>", first.summary)
|
||||
self.assertIn("<p>", first.content_html)
|
||||
|
||||
def test_list_items_keeps_feed_order_and_applies_limit(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
items = list_items(feed, limit=2)
|
||||
|
||||
self.assertEqual([item.id for item in items], [
|
||||
"https://news.hada.io/topic?id=28441",
|
||||
"https://news.hada.io/topic?id=28440",
|
||||
])
|
||||
|
||||
def test_search_items_matches_title_summary_and_author_case_insensitively(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
ai_matches = search_items(feed, query="agent", limit=5)
|
||||
author_matches = search_items(feed, query="WORKDRIVER", limit=5)
|
||||
|
||||
self.assertEqual([item.id for item in ai_matches], ["https://news.hada.io/topic?id=28440"])
|
||||
self.assertEqual([item.id for item in author_matches], ["https://news.hada.io/topic?id=28439"])
|
||||
|
||||
def test_get_item_detail_resolves_by_id_or_link_and_errors_cleanly(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
item = get_item_detail(feed, "https://news.hada.io/topic?id=28439")
|
||||
same_item = get_item_detail(feed, "28439")
|
||||
|
||||
self.assertEqual(item.title, "Show GN: [GN] 비개발자 + Claude로 프로덕션 운영 238일 — 무엇이 됐고 무엇이 안 됐나?")
|
||||
self.assertEqual(same_item.id, item.id)
|
||||
with self.assertRaisesRegex(LookupError, "No GeekNews entry matched"):
|
||||
get_item_detail(feed, "missing-topic")
|
||||
|
||||
|
||||
class GeekNewsPayloadShapeTest(unittest.TestCase):
|
||||
def test_list_search_and_detail_payloads_have_stable_json_shape(self):
|
||||
feed = load_feed(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
list_payload = build_list_payload(feed, limit=2)
|
||||
search_payload = build_search_payload(feed, query="claude", limit=5)
|
||||
detail_payload = build_detail_payload(feed, lookup="28439")
|
||||
|
||||
self.assertEqual(list_payload["source"]["title"], feed.title)
|
||||
self.assertEqual(list_payload["count"], 2)
|
||||
self.assertEqual(search_payload["query"], "claude")
|
||||
self.assertEqual(search_payload["count"], 1)
|
||||
self.assertEqual(detail_payload["item"]["id"], "https://news.hada.io/topic?id=28439")
|
||||
self.assertIn("summary", detail_payload["item"])
|
||||
self.assertIn("content_html", detail_payload["item"])
|
||||
|
||||
|
||||
class GeekNewsCliShapeTest(unittest.TestCase):
|
||||
def test_cli_prints_json_for_each_subcommand(self):
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["list", "--feed-file", str(FIXTURE_PATH), "--limit", "2"])
|
||||
listed = json.loads(stdout.getvalue())
|
||||
self.assertEqual(listed["count"], 2)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["search", "--feed-file", str(FIXTURE_PATH), "--query", "claude"])
|
||||
searched = json.loads(stdout.getvalue())
|
||||
self.assertEqual(searched["count"], 1)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with contextlib.redirect_stdout(stdout):
|
||||
main(["detail", "--feed-file", str(FIXTURE_PATH), "--id", "28439"])
|
||||
detail = json.loads(stdout.getvalue())
|
||||
self.assertEqual(detail["item"]["author_name"], "workdriver")
|
||||
|
||||
def test_helper_scripts_are_executable_python_entrypoints(self):
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
for helper in (
|
||||
repo_root / "scripts" / "geeknews_search.py",
|
||||
repo_root / "geeknews-search" / "scripts" / "geeknews_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()
|
||||
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()
|
||||
91
scripts/test_naver_blog_search.py
Normal file
91
scripts/test_naver_blog_search.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).resolve().parents[1] / "naver-blog-research" / "scripts" / "naver_search.py"
|
||||
MODULE_SPEC = importlib.util.spec_from_file_location("naver_search", MODULE_PATH)
|
||||
naver_search = importlib.util.module_from_spec(MODULE_SPEC)
|
||||
assert MODULE_SPEC.loader is not None
|
||||
MODULE_SPEC.loader.exec_module(naver_search)
|
||||
|
||||
|
||||
def make_result(index: int) -> dict[str, str]:
|
||||
return {
|
||||
"url": f"https://blog.naver.com/author{index}/{200000000000 + index}",
|
||||
"mobile_url": f"https://m.blog.naver.com/author{index}/{200000000000 + index}",
|
||||
"author": f"author{index}",
|
||||
"title": f"title-{index}",
|
||||
"snippet": f"snippet-{index}",
|
||||
}
|
||||
|
||||
|
||||
class RequestBuilderTest(unittest.TestCase):
|
||||
def test_build_search_params_target_blog_tab_and_switch_sm_for_paging(self):
|
||||
page_one = naver_search.build_search_params("서울 맛집", start=1, sort="sim")
|
||||
page_two = naver_search.build_search_params("서울 맛집", start=16, sort="date")
|
||||
|
||||
self.assertEqual(page_one["ssc"], "tab.blog.all")
|
||||
self.assertEqual(page_one["sm"], "tab_jum")
|
||||
self.assertEqual(page_one["start"], "1")
|
||||
self.assertEqual(page_one["nso"], "so:r,p:all,a:all")
|
||||
|
||||
self.assertEqual(page_two["ssc"], "tab.blog.all")
|
||||
self.assertEqual(page_two["sm"], "tab_pge")
|
||||
self.assertEqual(page_two["start"], "16")
|
||||
self.assertEqual(page_two["nso"], "so:dd,p:all,a:all")
|
||||
|
||||
|
||||
class SearchWorkflowTest(unittest.TestCase):
|
||||
def test_search_uses_15_result_pages_and_ignores_extra_anchors_beyond_page_window(self):
|
||||
fetch_starts: list[int] = []
|
||||
parsed_pages = {
|
||||
"page-1": [make_result(index) for index in range(1, 16)] + [make_result(101), make_result(102)],
|
||||
"page-16": [make_result(index) for index in range(16, 31)] + [make_result(101), make_result(102)],
|
||||
}
|
||||
|
||||
def fake_fetch(query: str, start: int = 1, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> str:
|
||||
self.assertEqual(query, "서울 맛집")
|
||||
self.assertEqual(sort, "sim")
|
||||
self.assertEqual(timeout, 15)
|
||||
self.assertFalse(insecure)
|
||||
fetch_starts.append(start)
|
||||
return f"page-{start}"
|
||||
|
||||
def fake_parse(html: str) -> list[dict]:
|
||||
return parsed_pages[html]
|
||||
|
||||
with (
|
||||
mock.patch.object(naver_search, "fetch_search_page", side_effect=fake_fetch),
|
||||
mock.patch.object(naver_search, "parse_search_results", side_effect=fake_parse),
|
||||
mock.patch.object(naver_search.time, "sleep"),
|
||||
):
|
||||
result = naver_search.search("서울 맛집", count=20)
|
||||
|
||||
self.assertEqual(fetch_starts, [1, 16])
|
||||
self.assertEqual(result["total_results"], 20)
|
||||
self.assertEqual(
|
||||
[item["url"] for item in result["results"]],
|
||||
[make_result(index)["url"] for index in range(1, 21)],
|
||||
)
|
||||
|
||||
def test_search_passes_date_sort_through_to_fetcher(self):
|
||||
captured_sorts: list[str] = []
|
||||
|
||||
def fake_fetch(query: str, start: int = 1, sort: str = "sim", timeout: int = 15, *, insecure: bool = False) -> str:
|
||||
captured_sorts.append(sort)
|
||||
return "page-1"
|
||||
|
||||
with (
|
||||
mock.patch.object(naver_search, "fetch_search_page", side_effect=fake_fetch),
|
||||
mock.patch.object(naver_search, "parse_search_results", return_value=[make_result(1)]),
|
||||
):
|
||||
result = naver_search.search("서울 맛집", count=1, sort="date")
|
||||
|
||||
self.assertEqual(captured_sorts, ["date"])
|
||||
self.assertEqual(result["results"][0]["url"], make_result(1)["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()
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue