Merge pull request #244 from NomaDamas/dev

Sync dev → main: 신규 스킬 8종 + NTS proxy 라우팅 + k-skill-setup 보강
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 00:42:35 +09:00 committed by GitHub
commit 9cb2ea037e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 6865 additions and 10 deletions

View file

@ -0,0 +1,5 @@
---
"gangnamunni-clinic-search": minor
---
Add Gangnam Unni public clinic search skill and package.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": patch
---
Add National Tax Service business registration status and authenticity proxy routes.

View file

@ -0,0 +1,5 @@
---
"daishin-report-search": minor
---
Add a Daishin Securities report search skill backed by the public GitHub Pages report mirror.

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add `/v1/seoul-density/citydata` route that proxies the Seoul Open Data realtime hotspot crowd-level API (`citydata_ppltn`) using the server-side `SEOUL_OPEN_API_KEY`.

View file

@ -16,14 +16,31 @@ permissions:
id-token: write
jobs:
detect_python_packages:
runs-on: ubuntu-latest
outputs:
has_python_packages: ${{ steps.detect.outputs.has_python_packages }}
steps:
- uses: actions/checkout@v4
- id: detect
shell: bash
run: |
if find python-packages -mindepth 2 -maxdepth 2 -name pyproject.toml -print -quit | grep -q .; then
echo "has_python_packages=true" >> "$GITHUB_OUTPUT"
else
echo "has_python_packages=false" >> "$GITHUB_OUTPUT"
fi
scaffold-only:
if: ${{ hashFiles('python-packages/**/pyproject.toml') == '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages != 'true' }}
runs-on: ubuntu-latest
steps:
- run: echo "No Python package exists yet. release-please remains scaffold-only."
release:
if: ${{ hashFiles('python-packages/**/pyproject.toml') != '' }}
needs: detect_python_packages
if: ${{ needs.detect_python_packages.outputs.has_python_packages == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

View file

@ -29,6 +29,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
@ -38,6 +39,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-search` | 한국 법령/조문/판례/유권해석 검색 | 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
| 한국 개인정보처리방침·이용약관 자동 생성 | `korean-privacy-terms` | Next.js 프로젝트에 개인정보보호법·약관규제법·전자상거래법 기반 개인정보처리방침/이용약관/쿠키 배너/동의 모달을 생성하는 `kimlawtech/korean-privacy-terms` (Apache-2.0) thin wrapper | 불필요 | [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md) |
| 한국 부동산 실거래가 조회 | `real-estate-search` | 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회 | 불필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
@ -53,6 +55,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 대신증권 리포트 조회 | `daishin-report-search` | GitHub Pages에 공개된 대신증권 리포트 HTML 미러에서 최신 리포트 목록, 원문, 설명 페이지, Rating/Target 표를 조회 | 불필요 | [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | `korean-patent-search` | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
@ -67,6 +70,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 토스증권 조회 | `toss-securities` | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 하이패스 영수증 발급 | `hipass-receipt` | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 공연 일정·잔여석 조회 | `ticket-availability` | YES24·인터파크 공연의 회차별 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음) | 불필요 | [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md) |
| 로또 당첨 확인 | `lotto-results` | 로또 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
@ -75,6 +79,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 강남언니 병원 조회 | `gangnamunni-clinic-search` | 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 링크 조회 | 불필요 | [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
| 올라포케 역삼 포케 | `hola-poke-yeoksam` | 올라포케 역삼점 메뉴, 매장 정보, 이벤트 참여 흐름 안내 | 불필요 | [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md) |
@ -82,6 +87,10 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 택배 배송조회 | `delivery-tracking` | CJ대한통운·우체국 송장 번호로 배송 상태 조회 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
| 쿠팡 상품 검색 | `coupang-product-search` | 쿠팡 상품 검색, 로켓배송 필터, 가격대 검색, 비교, 베스트, 골드박스 특가 조회 | 선택사항 (운영 키 있으면 로컬 HMAC 경로, 없으면 hosted fallback) | [쿠팡 상품 검색 가이드](docs/features/coupang-product-search.md) |
| 번개장터 검색 | `bunjang-search` | 번개장터 검색, 상세조회, 선택적 찜/채팅, AI TOON export | 불필요 | [번개장터 검색 가이드](docs/features/bunjang-search.md) |
| 당근 중고거래 검색 | `daangn-used-goods-search` | 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회 | 불필요 | [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md) |
| 당근부동산 검색 | `daangn-realty-search` | 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인 | 불필요 | [당근부동산 검색 가이드](docs/features/daangn-realty-search.md) |
| 당근알바 검색 | `daangn-jobs-search` | 당근알바 공개 웹 데이터 표면으로 키워드·지역 기반 알바 공고 검색과 상세 조회 | 불필요 | [당근알바 검색 가이드](docs/features/daangn-jobs-search.md) |
| 당근중고차 검색 | `daangn-cars-search` | 당근중고차 공개 웹 데이터 표면으로 지역·가격 조건 기반 차량 검색과 상세 조회 | 불필요 | [당근중고차 검색 가이드](docs/features/daangn-cars-search.md) |
| 중고차 가격 조회 | `used-car-price-search` | 중고차 인수가/월 렌트료 비교 조회 | 불필요 | [중고차 가격 조회 가이드](docs/features/used-car-price-search.md) |
| 한국어 맞춤법 검사 | `korean-spell-check` | 한국어 텍스트 맞춤법/문법 검사 및 교정안 정리 | 불필요 | [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md) |
| 네이버 블로그 리서치 | `naver-blog-research` | 네이버 블로그 검색, 원문 읽기, 이미지 다운로드, 한국어 콘텐츠 교차 검증 | 불필요 | [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md) |
@ -131,6 +140,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
@ -139,6 +149,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [개별공시지가 조회 가이드](docs/features/gongsijiga-search.md)
@ -164,8 +175,10 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)
- [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md)
- [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md)
- [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md)
- [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md)
- [로또 당첨 확인](docs/features/lotto-results.md)
- [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md)
- [법인등기 신청 컨설팅](docs/features/corporate-registration-consulting.md)
@ -176,6 +189,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md)
- [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md)
- [올리브영 검색 가이드](docs/features/olive-young-search.md)
- [올라포케 역삼 포케 가이드](docs/features/hola-poke-yeoksam.md)
@ -183,6 +197,10 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [택배 배송조회](docs/features/delivery-tracking.md)
- [쿠팡 상품 검색](docs/features/coupang-product-search.md)
- [번개장터 검색 가이드](docs/features/bunjang-search.md)
- [당근 중고거래 검색 가이드](docs/features/daangn-used-goods-search.md)
- [당근부동산 검색 가이드](docs/features/daangn-realty-search.md)
- [당근알바 검색 가이드](docs/features/daangn-jobs-search.md)
- [당근중고차 검색 가이드](docs/features/daangn-cars-search.md)
- [중고차 가격 조회 가이드](docs/features/used-car-price-search.md)
- [한국어 맞춤법 검사 가이드](docs/features/korean-spell-check.md)
- [네이버 블로그 리서치 가이드](docs/features/naver-blog-research.md)

101
daangn-cars-search/SKILL.md Normal file
View file

@ -0,0 +1,101 @@
---
name: daangn-cars-search
description: 당근중고차 공개 웹 데이터 표면으로 지역·가격 조건 기반 차량 검색과 상세 조회를 수행한다. 문의/구매 자동화는 제외한다.
license: MIT
metadata:
category: automotive
locale: ko-KR
phase: v1
---
# Daangn Cars Search
## What this skill does
당근중고차 공개 Remix `_data` JSON route를 사용해 차량 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근중고차 합정동 레이 찾아봐"
- "당근에서 천만원 이하 중고차 검색"
- "이 당근 중고차 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/cars/?in=<지역명>-<id>&onlyOnSale=1&_data=routes/kr.cars._index`
- Detail `_data`: `<car-url>?_data=routes%2Fkr.cars.%24car_post_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-cars-search/scripts/daangn_cars.py search "레이" --region "합정동" --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py search --region "합정동" --price-max 10000000 --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py detail "https://www.daangn.com/kr/cars/.../"
```
## Output fields
- title, price, price_text, region, status, driveDistance, carData, chatRoomCount, url
- detail: carPost 원문
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.only_on_sale: params.append(('onlyOnSale','1'))
if args.price_max: params.append(('priceMax', str(args.price_max)))
if args.price_min: params.append(('priceMin', str(args.price_min)))
params.append(('_data','routes/kr.cars._index'))
url='https://www.daangn.com/kr/cars/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('carAllPage') or {}).get('carPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in (a.get('title') or '').lower()]
arr=arr[:args.limit]
items=[{'title':a.get('title'),'price':a.get('price'),'price_text':won(a.get('price')),'region':(a.get('region') or {}).get('name'),
'status':a.get('status'),'driveDistance':a.get('driveDistance'),'carData':a.get('carData'),
'chatRoomCount':a.get('chatRoomCount'),'url':absolute(a.get('href'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.cars.%24car_post_id'
data=fetch_json(u); print_json({'source':u,'carPost':data.get('carPost') or data})
p=argparse.ArgumentParser(description='Daangn cars read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--price-min',type=int); s.add_argument('--price-max',type=int); s.add_argument('--only-on-sale',action='store_true',default=True); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

100
daangn-jobs-search/SKILL.md Normal file
View file

@ -0,0 +1,100 @@
---
name: daangn-jobs-search
description: 당근알바 공개 웹 데이터 표면으로 키워드·지역 기반 알바 공고 검색과 상세 조회를 수행한다. 지원/채팅 자동화는 제외한다.
license: MIT
metadata:
category: jobs
locale: ko-KR
phase: v1
---
# Daangn Jobs Search
## What this skill does
당근알바 공개 Remix `_data` JSON route로 채용/알바 공고 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근알바 합정동 카페 찾아봐"
- "홍대 근처 주말 알바 검색"
- "이 당근알바 공고 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/jobs/?in=<지역명>-<id>&search=<keyword>&_data=routes/kr.jobs._index`
- Detail fallback: `<job-url>` redirects to `jobs.daangn.com/job-posts/<id>` and exposes public HTML title/meta/JSON-LD. The helper first tries the legacy `_data` route and falls back to HTML meta when that route returns an empty response.
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-jobs-search/scripts/daangn_jobs.py search "카페" --region "합정동" --limit 5
python3 daangn-jobs-search/scripts/daangn_jobs.py detail "https://www.daangn.com/kr/jobs/.../"
```
## Output fields
- title, company, region, address, salary, salaryType, workDays, workTimeStart, workTimeEnd, closed, url
- detail: `jobPost` 원문 if the `_data` route is available; otherwise public page `title`, `meta`, and `json_ld`
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
body = r.read()
if not body:
raise ValueError(f'빈 JSON 응답: {url}')
return json.loads(body)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def parse_html_detail(url):
html = fetch_text(url)
title = re.search(r'<title>(.*?)</title>', html, re.S)
meta = {}
for m in re.finditer(r'<meta[^>]+(?:property|name)=["\']([^"\']+)["\'][^>]+content=["\']([^"\']*)["\']', html):
key, value = m.group(1), unescape(m.group(2)).strip()
if key in ('description', 'og:title', 'og:description', 'og:image'):
meta[key] = value
json_ld = []
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S):
try:
json_ld.append(json.loads(unescape(m.group(1))))
except Exception:
pass
return {
'source': url,
'title': unescape(title.group(1)).strip() if title else meta.get('og:title'),
'meta': meta,
'json_ld': json_ld[:3],
}
def cmd_search(args):
sel=resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.keyword: params.append(('search', args.keyword))
params.append(('_data','routes/kr.jobs._index'))
url='https://www.daangn.com/kr/jobs/?'+urllib.parse.urlencode(params)
data=fetch_json(url); arr=((data.get('jobsAllPage') or {}).get('jobPosts') or [])[:args.limit]
items=[{'title':a.get('title'),'company':a.get('workplaceCompanyName'),'region':a.get('workplaceRegion'),
'address':a.get('workplaceRoadNameAddress'),'salary':a.get('salary'),'salaryType':a.get('salaryType'),
'workDays':a.get('workDays'),'workTimeStart':a.get('workTimeStart'),'workTimeEnd':a.get('workTimeEnd'),
'closed':a.get('closed'),'url':absolute(a.get('href') or a.get('jobsWebDetailUrl'))} for a in arr]
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
u=args.url.rstrip('/')+'/?_data=routes%2Fkr.jobs.%24job_post_id'
try:
data=fetch_json(u)
print_json({'source':u,'jobPost':data.get('jobPost') or data})
except Exception:
detail = parse_html_detail(args.url)
detail['data_source_attempted'] = u
print_json(detail)
p=argparse.ArgumentParser(description='Daangn jobs read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword', nargs='?'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,101 @@
---
name: daangn-realty-search
description: 당근부동산 공개 웹 데이터 표면으로 지역 기반 부동산 매물 검색과 상세 확인을 수행한다. 문의/예약/계약 자동화는 제외한다.
license: MIT
metadata:
category: real-estate
locale: ko-KR
phase: v1
---
# Daangn Realty Search
## What this skill does
당근부동산 목록의 공개 Remix `_data` JSON과 상세 페이지의 JSON-LD/HTML 메타를 읽어 매물 후보를 정리한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근부동산 합정동 전세 찾아봐"
- "마포구 월세 매물 봐줘"
- "이 당근부동산 URL 상세 요약해줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/realty/?in=<지역명>-<id>&_data=routes/kr.realty._index`
- Detail: `https://realty.daangn.com/articles/<id>``application/ld+json``<title>`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --limit 5
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --sales-type "APARTMENT" --trade-type "MONTHLY_RENT"
python3 daangn-realty-search/scripts/daangn_realty.py detail "https://realty.daangn.com/articles/..."
```
## Output fields
- title, salesType, trade, area, areaPyeong, totalManageCost, url
- detail: JSON-LD, page title
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def norm_trade(t):
if not t: return None
return t
def cmd_search(args):
sel = resolve_region(args.region) if args.region else None
params=[]
if sel: params.append(('in', f"{sel['name']}-{sel['id']}"))
if args.sales_type: params.append(('salesType', args.sales_type))
if args.trade_type: params.append(('tradeType', args.trade_type))
if args.only_verified: params.append(('onlyVerified','true'))
params.append(('_data','routes/kr.realty._index'))
url='https://www.daangn.com/kr/realty/?'+urllib.parse.urlencode(params)
data=fetch_json(url)
arr=((data.get('realtyPosts') or {}).get('realtyPosts') or [])
if args.keyword:
arr=[a for a in arr if args.keyword.lower() in json.dumps(a, ensure_ascii=False).lower()]
arr=arr[:args.limit]
items=[]
for a in arr:
tr=(a.get('trades') or [{}])[0]
items.append({'title':a.get('title'),'salesType':a.get('salesType') or a.get('salesTypeV2'),'trade':tr,
'area':a.get('area'),'areaPyeong':a.get('areaPyeong'),'totalManageCost':a.get('totalManageCost'),
'url':a.get('webUrl') or absolute(a.get('href'))})
print_json({'source':url,'effective_region':data.get('searchRegion') or sel,'count':len(items),'items':items})
def cmd_detail(args):
html=fetch_text(args.url)
lds=[]
for m in re.finditer(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>', html, re.S):
try: lds.append(json.loads(unescape(m.group(1))))
except Exception: pass
title=re.search(r'<title>(.*?)</title>', html, re.S)
print_json({'source':args.url,'title':unescape(title.group(1)).strip() if title else None,'json_ld':lds[:3]})
p=argparse.ArgumentParser(description='Daangn realty read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('--region'); s.add_argument('--keyword'); s.add_argument('--sales-type'); s.add_argument('--trade-type'); s.add_argument('--only-verified',action='store_true'); s.add_argument('--limit',type=int,default=10); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,100 @@
---
name: daangn-used-goods-search
description: 당근 중고거래 공개 웹 데이터 표면으로 키워드·지역 기반 매물 검색과 상세 조회를 수행한다. 로그인/채팅/찜/구매 자동화는 제외한다.
license: MIT
metadata:
category: marketplace
locale: ko-KR
phase: v1
---
# Daangn Used-Goods Search
## What this skill does
당근 중고거래 공개 Remix `_data` JSON route를 사용해 매물 목록과 상세 정보를 읽기 전용으로 조회한다.
최종 사용자는 자연어로 요청해도 되고, 필요하면 아래의 Python helper를 직접 실행한다. 외부 패키지나 k-skill-proxy 없이 Python 표준 라이브러리만 사용한다.
## When to use
- "당근에서 맥북 찾아봐"
- "합정동 아이폰 매물 검색"
- "이 당근 중고거래 URL 상세 봐줘"
## When not to use
- 당근 계정 로그인이 필요한 작업
- 채팅, 찜, 거래 제안, 문의, 지원, 예약, 계약, 구매처럼 상대방 또는 계정에 영향을 주는 작업
- CAPTCHA/봇 차단/로그인벽 우회가 필요한 작업
## Prerequisites
- 인터넷 연결
- Python 3.9+
- 이 저장소 루트에서 실행하거나, 스크립트 경로를 절대경로로 지정
## Data surfaces
- Region resolver: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
- Search `_data`: `/kr/buy-sell/all/?in=<지역명>-<id>&search=<keyword>&only_on_sale=true&_data=routes/kr.buy-sell._index`
- Detail `_data`: `<listing-url>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
## Workflow
1. 사용자 요청에서 키워드, 지역명, 가격/거래 유형 같은 필터를 추출한다.
2. 지역명이 있으면 region resolver로 내부 region id를 찾는다.
3. 목록 검색은 category별 `_data` route를 호출한다.
4. 상세 URL이 주어지면 category별 detail route 또는 공개 HTML 메타를 조회한다.
5. 결과를 짧게 정리하되 source URL과 적용 지역을 보존한다.
## Commands
```bash
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
```
## Output fields
- title, price, price_text, status, region, url
- detail: product 원문, view/chat/count류 필드가 있으면 함께 확인
## Region handling
지역 필터가 있으면 먼저 당근 지역 검색 API로 내부 지역 id를 해석한다.
```text
https://www.daangn.com/kr/api/v1/regions/keyword?keyword=합정동
→ 서울특별시 마포구 합정동, id=231
→ in=합정동-231
```
동일한 지명이 여러 지역에 있으면 다음 우선순위로 선택한다.
1. 사용자가 입력한 문자열이 `name`, `name1`, `name2`, `name3` 중 하나와 정확히 맞는 후보
2. 서울 `depth=3` 동 단위 후보
3. 첫 번째 후보
응답에는 항상 `effective_region` 또는 실제 적용된 지역명을 포함한다. 사용자의 의도와 다른 지역으로 보이면 결과를 단정하지 말고 후보 확인을 요청한다. IP/쿠키 기본 위치에 의존하지 않는다.
## Safety and scope
- 읽기 전용 검색/상세 조회만 수행한다.
- 로그인, 채팅, 찜, 거래 제안, 지원, 문의, 예약, 계약, 구매 자동화는 하지 않는다.
- 공개 웹 표면이 바뀌거나 빈 응답/봇 차단/로그인벽이 나오면 실패 모드로 보고하고 우회하지 않는다.
- 결과는 실시간 재고/공고 상태와 달라질 수 있으므로 source URL을 함께 제시한다.
## Failure modes
- 당근의 Remix route 이름이나 JSON shape가 변경되면 `_data` 조회가 실패할 수 있다.
- 지역명이 넓거나 중복되면 다른 행정동이 선택될 수 있다.
- 검색 결과가 0건이어도 사이트 정책/지역 기본값/필터 조합 때문일 수 있으므로 source URL을 보존한다.
- 상세 조회는 삭제/종료/비공개 전환된 글에서 실패할 수 있다.
## Done when
- 지역명이 있으면 지역 id를 해석하고 적용했다.
- 목록 조회 또는 상세 조회를 최소 1회 수행했다.
- 결과에 source URL과 effective region을 포함했다.
- 인증/거래성 액션은 수행하지 않았다.

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
import argparse, json, re, sys, urllib.parse, urllib.request
from html import unescape
HEADERS = {"User-Agent":"Mozilla/5.0", "Accept":"application/json,text/html;q=0.9,*/*;q=0.8"}
def fetch_json(url):
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, timeout=25) as r:
return json.load(r)
def fetch_text(url):
req = urllib.request.Request(url, headers={"User-Agent":"Mozilla/5.0", "Accept":"text/html"})
with urllib.request.urlopen(req, timeout=25) as r:
return r.read().decode('utf-8', 'ignore')
def won(v):
if v in (None, ''): return '-'
try: return f"{int(float(v)):,}"
except Exception: return str(v)
def resolve_region(region):
if not region: return None
url = 'https://www.daangn.com/kr/api/v1/regions/keyword?keyword=' + urllib.parse.quote(region)
data = fetch_json(url)
locs = data.get('locations') or []
if not locs: raise SystemExit(f'지역 후보 없음: {region}')
# Exact dong/name match first, then Seoul depth-3, then first candidate.
exact = [x for x in locs if region in (x.get('name'), x.get('name1'), x.get('name2'), x.get('name3'))]
seoul = [x for x in locs if x.get('name1') == '서울특별시' and x.get('depth') == 3]
sel = (exact or seoul or locs)[0]
return sel
def region_param(sel):
return urllib.parse.quote(f"{sel['name']}-{sel['id']}")
def absolute(href):
if not href: return ''
if href.startswith('http'): return href
return 'https://www.daangn.com' + href
def print_json(obj):
print(json.dumps(obj, ensure_ascii=False, indent=2))
def cmd_search(args):
params = []
effective = None
path = '/kr/buy-sell/'
if args.region:
effective = resolve_region(args.region)
path = '/kr/buy-sell/all/'
params.append(('in', f"{effective['name']}-{effective['id']}"))
params.append(('search', args.keyword))
if args.only_on_sale: params.append(('only_on_sale','true'))
params.append(('_data','routes/kr.buy-sell._index'))
url = 'https://www.daangn.com' + path + '?' + urllib.parse.urlencode(params)
data = fetch_json(url)
arr = (((data.get('allPage') or {}).get('fleamarketArticles')) or [])[:args.limit]
print_json({
'source': url,
'effective_region': effective or data.get('region'),
'count': len(arr),
'items': [{
'title': a.get('title'), 'price': a.get('price'), 'price_text': won(a.get('price')),
'region': (a.get('region') or {}).get('name'), 'status': a.get('status'),
'url': absolute(a.get('href') or a.get('webUrl')),
} for a in arr]
})
def cmd_detail(args):
u = args.url.rstrip('/') + '/?_data=routes%2Fkr.buy-sell.%24buy_sell_id'
data = fetch_json(u); p = data.get('product') or data.get('article') or data
print_json({'source': u, 'product': p})
p=argparse.ArgumentParser(description='Daangn used-goods read-only search/detail')
sub=p.add_subparsers(dest='cmd', required=True)
s=sub.add_parser('search'); s.add_argument('keyword'); s.add_argument('--region'); s.add_argument('--limit',type=int,default=10); s.add_argument('--only-on-sale',action='store_true',default=True); s.set_defaults(func=cmd_search)
d=sub.add_parser('detail'); d.add_argument('url'); d.set_defaults(func=cmd_detail)
args=p.parse_args(); args.func(args)

View file

@ -0,0 +1,148 @@
---
name: daishin-report-search
description: 대신증권 리포트 GitHub Pages 미러에서 최신 HTML 리포트 목록과 원문/설명 페이지를 조회한다.
license: MIT
metadata:
category: finance
locale: ko-KR
phase: v1
---
# Daishin Report Search
## What this skill does
대신증권 리포트 HTML 미러(`jay-jo-0/github_pages_repo`)에서 최신 리포트 목록을 찾고, 특정 리포트의 원문 텍스트·제목·헤딩·Rating/Target 표·원문 링크를 에이전트가 재사용하기 쉬운 JSON으로 반환한다.
이 스킬은 투자 조언, 매매 자동화, 추천을 하지 않는다. 공개 HTML 리포트를 읽어 요약 가능한 자료로 정리하는 조회 전용 스킬이다.
## When to use
- "대신증권 최신 리포트 보여줘"
- "대신증권 반도체 리포트 찾아줘"
- "20260511082352 리포트 원문과 설명 페이지를 읽어줘"
- "대신증권 리포트 목록을 에이전트가 쓰기 좋은 JSON으로 줘"
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `daishin-report-search` npm package 또는 동일 로직
## Public access path discovered
### Primary source: GitHub recursive tree API
- list endpoint: `https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1`
- selected paths: repository-root files matching `YYYYMMDDHHMMSS.html`
- optional companion paths: `YYYYMMDDHHMMSS_explain.html`
- detail raw HTML: `https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/<path>`
- browser detail URL: `https://jay-jo-0.github.io/github_pages_repo/<path>`
- reason selected: the sample GitHub Pages URL maps directly to a public GitHub repository. The recursive tree API exposes all timestamped HTML filenames without relying on a brittle directory listing screen scrape. Raw GitHub URLs provide stable unauthenticated detail fetches.
### Fallback source: GitHub contents API for an exact file
- exact-file endpoint: `https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/<path>?ref=main`
- used automatically for a known timestamp when the raw detail URL is unavailable; it also provides GitHub content metadata for manual diagnostics.
No `k-skill-proxy` route is used because the upstream is public and does not require an API key.
## Workflow
### 1. List latest reports
```js
const { listReports } = require("daishin-report-search")
const result = await listReports({
limit: 10,
query: "반도체", // optional; matches title/headings/detail text
maxInspect: 100, // optional query crawl budget among newest pages
githubToken: process.env.GITHUB_TOKEN // optional; raises GitHub API limits when caller has one
})
console.log(result.items)
```
CLI:
```bash
node packages/daishin-report-search/src/cli.js --limit 10
node packages/daishin-report-search/src/cli.js 반도체 --limit 5 --max-inspect 100
```
Return each item with:
- `id` (`YYYYMMDDHHMMSS`)
- `date`, `time`, `timestamp` (filename-derived KST timestamp)
- `title`
- `headings`
- `excerpt`
- `ratingTargets` when a Rating/Target table is present
- `pageUrl`, `rawUrl`, `apiUrl`
- `hasExplain`, `explainUrl` when a companion explanation page exists
### 2. Fetch one report
```js
const { fetchReport } = require("daishin-report-search")
const report = await fetchReport("20260511082352", {
includeExplain: true
})
console.log(report.title)
console.log(report.text)
console.log(report.explain?.text)
```
CLI:
```bash
node packages/daishin-report-search/src/cli.js --id 20260511082352 --include-explain
```
### 3. Summarize conservatively
When answering a user, show:
```text
- 제목: ...
게시 추정 시각: 2026-05-11 08:23:52 KST (파일명 기준)
주요 헤딩: ...
Rating/Target: ... (있는 경우)
원문: https://jay-jo-0.github.io/github_pages_repo/...
설명 페이지: ... (있는 경우)
```
Always state that the timestamp is filename-derived and that report contents can change in the public mirror.
## Fallback order
1. GitHub recursive tree API → filter timestamped root HTML files → sort newest filename first → fetch raw detail HTML for selected/latest candidates.
2. If a query is present, inspect newer candidates up to `maxInspect` until enough matches are found or the budget is exhausted; return a warning if the budget is exhausted.
3. For a known id, fetch raw detail directly. If explanation is requested, fetch `<id>_explain.html`; if absent, return the original report plus a warning.
4. If the tree endpoint is truncated, blocked, rate-limited, or changed, report that as a source warning/failure instead of guessing hidden pages.
5. For a known id, if the raw detail URL fails, fall back to the GitHub contents API for that exact file path. Explanation pages use the same exact-file fallback but remain optional and return a warning if unavailable.
6. If the caller has authenticated GitHub access, pass `githubToken` / `githubHeaders` in library calls or set `DAISHIN_GITHUB_TOKEN` / `GITHUB_TOKEN` for the CLI; these credentials are scoped to `api.github.com` requests and are not sent to raw detail URLs. Do not require or proxy a token by default.
## Done when
- Latest report rows or a specific report are returned with direct source URLs.
- Query and limit were applied or explicitly left broad.
- Explanation pages were included only when requested or when listing metadata shows they exist.
- Empty results and upstream warnings are disclosed.
## Failure modes
- GitHub unauthenticated API rate limits can return 403/429; latest/search returns empty `items` plus `source.error.kind = "rate_limit"` and rate-limit reset metadata when GitHub exposes it. Retry later or use caller-supplied authenticated GitHub access if appropriate.
- The repository path or branch can change; then tree/raw URLs will fail.
- The tree response could become truncated; in that case the latest-list completeness is not guaranteed.
- HTML structure can change; title/headings/table extraction may be partial, but URLs and raw text fallback should still be returned when available.
- Some pages may not be authored by Daishin even though they are in the issue-scoped public mirror. Do not infer provenance beyond page title/content.
## Notes
- Read-only lookup only; no login, trading, order placement, recommendation, or investment advice.
- Do not scrape private Daishin services or bypass CAPTCHA/login walls.
- No secrets or API keys are required. Optional GitHub tokens are caller-owned, used only when explicitly supplied via options or environment, and scoped to GitHub API hosts.

View file

@ -0,0 +1,43 @@
# 당근중고차 검색 가이드 (`daangn-cars-search`)
당근중고차 공개 웹 데이터 표면을 사용해 지역·키워드·가격 조건 기반 차량을 검색하고, 개별 차량 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근중고차 합정동 레이 찾아봐"
- "당근에서 천만원 이하 중고차 검색해줘"
- "이 당근 중고차 URL 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 문의, 구매 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/cars/?in=<지역명>-<id>&onlyOnSale=1&_data=routes/kr.cars._index`
3. 상세: `<차량 URL>?_data=routes%2Fkr.cars.%24car_post_id`
## 로컬 실행
```bash
python3 daangn-cars-search/scripts/daangn_cars.py search "레이" --region "합정동" --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py search --region "합정동" --price-max 10000000 --limit 5
python3 daangn-cars-search/scripts/daangn_cars.py detail "https://www.daangn.com/kr/cars/.../"
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `price`, `price_text`, `region`, `status`, `driveDistance`, `carData`, `chatRoomCount`, `url`을 우선 확인합니다. 차량 연식, 주행거리, 사고/정비 이력처럼 원문 의존도가 높은 정보는 상세 조회의 `carPost` 원문을 함께 확인합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 문의, 시승 예약, 구매, 결제, 채팅 자동화는 실행하지 않습니다.
- 가격·판매 상태는 실시간으로 바뀔 수 있어 원문 URL을 함께 제시합니다.

View file

@ -0,0 +1,42 @@
# 당근알바 검색 가이드 (`daangn-jobs-search`)
당근알바 공개 웹 데이터 표면을 사용해 키워드·지역 기반 알바 공고를 검색하고, 개별 공고 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근알바 합정동 카페 알바 찾아봐"
- "홍대 근처 주말 알바 검색해줘"
- "이 당근알바 공고 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 지원, 문의 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/jobs/?in=<지역명>-<id>&search=<키워드>&_data=routes/kr.jobs._index`
3. 상세: `<공고 URL>``jobs.daangn.com/job-posts/<id>` 공개 HTML의 title/meta/JSON-LD(헬퍼는 legacy `_data`를 먼저 시도 후 빈 응답이면 HTML 메타로 fallback)
## 로컬 실행
```bash
python3 daangn-jobs-search/scripts/daangn_jobs.py search "카페" --region "합정동" --limit 5
python3 daangn-jobs-search/scripts/daangn_jobs.py detail "https://www.daangn.com/kr/jobs/.../"
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `company`, `region`, `address`, `salary`, `salaryType`, `workDays`, `workTimeStart`, `workTimeEnd`, `closed`, `url`을 우선 확인합니다. 상세 조회는 가능하면 `jobPost` 원문을 사용하고, 공개 `_data`가 빈 응답이면 HTML title/meta/JSON-LD를 근거로 정리합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 마감·삭제·비공개 전환된 공고는 상세 조회가 실패할 수 있습니다.
- 지원, 채팅, 문의, 개인정보 제출 자동화는 범위 밖입니다.

View file

@ -0,0 +1,43 @@
# 당근부동산 검색 가이드 (`daangn-realty-search`)
당근부동산 공개 웹 데이터 표면을 사용해 지역 기반 부동산 매물 후보를 검색하고, 상세 페이지의 공개 메타를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근부동산 합정동 월세 매물 찾아봐"
- "마포구 전세 후보 당근에서 봐줘"
- "이 당근부동산 URL 상세 요약해줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 문의, 예약, 계약 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/realty/?in=<지역명>-<id>&_data=routes/kr.realty._index`
3. 상세: `https://realty.daangn.com/articles/<id>``application/ld+json``<title>`
## 로컬 실행
```bash
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --limit 5
python3 daangn-realty-search/scripts/daangn_realty.py search --region "합정동" --sales-type "APARTMENT" --trade-type "MONTHLY_RENT"
python3 daangn-realty-search/scripts/daangn_realty.py detail "https://realty.daangn.com/articles/..."
```
## 지역 필터
지역명은 당근 region API로 내부 id를 해석한 뒤 `in=<지역명>-<id>` 형태로 검색 URL에 넣습니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
## 출력 해석
검색 결과는 `title`, `salesType`, `trade`, `area`, `areaPyeong`, `totalManageCost`, `url`을 우선 확인합니다. 부동산 판단에는 실시간 상태, 보증금/월세, 관리비, 면적, 중개/직거래 여부가 중요하므로 원본 URL을 함께 제시합니다.
## 제한사항
- 당근부동산 목록 JSON과 `realty.daangn.com` 상세 HTML 구조 변경에 영향을 받습니다.
- 문의, 방문 예약, 계약, 결제, 채팅은 실행하지 않습니다.
- 공고 내용은 실시간 상태와 달라질 수 있어 최종 판단 전 원문 확인이 필요합니다.

View file

@ -0,0 +1,45 @@
# 당근 중고거래 검색 가이드 (`daangn-used-goods-search`)
당근 중고거래 공개 웹 데이터 표면을 사용해 키워드·지역 기반 매물을 검색하고, 개별 매물 상세를 읽기 전용으로 확인하는 스킬입니다.
## 사용 시나리오
- "당근에서 합정동 맥북 매물 찾아봐"
- "이 당근 중고거래 URL 상세 요약해줘"
- "아이폰 15 Pro 중고 매물 중 판매중인 것만 봐줘"
## 구현 표면
브라우저 자동화, 로그인, 채팅, 찜, 거래 제안, 구매 자동화를 사용하지 않습니다.
1. 지역 해석: `https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>`
2. 검색: `https://www.daangn.com/kr/buy-sell/all/?in=<지역명>-<id>&search=<키워드>&only_on_sale=true&_data=routes/kr.buy-sell._index`
3. 상세: `<매물 URL>?_data=routes%2Fkr.buy-sell.%24buy_sell_id`
## 로컬 실행
```bash
python3 daangn-used-goods-search/scripts/daangn_used_goods.py search "맥북" --region "합정동" --limit 5
python3 daangn-used-goods-search/scripts/daangn_used_goods.py detail "https://www.daangn.com/kr/buy-sell/.../"
```
## 지역 필터
지역명은 바로 URL에 넣지 않고 당근 region API로 내부 id를 먼저 조회합니다.
```text
합정동 → 서울특별시 마포구 합정동, id=231 → in=합정동-231
```
동일 지명이 여러 곳에 있으면 정확 일치 후보, 서울 동 단위 후보, 첫 번째 후보 순으로 선택합니다. 결과에는 적용 지역(`effective_region`)과 원본 URL을 함께 남깁니다.
## 출력 해석
검색 결과는 `title`, `price`, `price_text`, `status`, `region`, `url` 중심으로 1차 후보를 고릅니다. 조회수, 채팅수, 설명 같은 상세 판단은 상세 조회 결과의 `product` 원문을 확인한 뒤 정리합니다.
## 제한사항
- 공개 Remix `_data` route 이름이나 JSON shape가 바뀌면 실패할 수 있습니다.
- 삭제·판매완료·비공개 전환된 글은 상세 조회가 실패할 수 있습니다.
- CAPTCHA, 로그인벽, 봇 차단이 나오면 실패 모드로 보고하고 우회하지 않습니다.
- 상대방에게 영향을 주는 채팅, 찜, 거래 제안, 구매 자동화는 범위 밖입니다.

View file

@ -0,0 +1,45 @@
# 대신증권 리포트 조회 가이드
`daishin-report-search``jay-jo-0/github_pages_repo` GitHub Pages 미러에 올라오는 대신증권 리포트 HTML을 최신순으로 찾고 원문/설명 페이지를 JSON으로 정리하는 조회 전용 스킬이다.
## 공개 접근 경로
- 목록: `https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1`
- 원문 HTML: `https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/<YYYYMMDDHHMMSS.html>`
- exact-file fallback: `https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/<YYYYMMDDHHMMSS.html>?ref=main`
- 브라우저 URL: `https://jay-jo-0.github.io/github_pages_repo/<YYYYMMDDHHMMSS.html>`
- 설명 페이지: `<YYYYMMDDHHMMSS_explain.html>`이 있을 때만 제공
파일명 timestamp를 KST 게시 추정 시각으로 표시한다. GitHub API와 raw 파일은 공개 unauthenticated endpoint라서 proxy를 쓰지 않는다.
## 사용 예시
```bash
node packages/daishin-report-search/src/cli.js --limit 10
GITHUB_TOKEN=... node packages/daishin-report-search/src/cli.js --limit 10
node packages/daishin-report-search/src/cli.js 반도체 --limit 5 --max-inspect 100
node packages/daishin-report-search/src/cli.js --id 20260511082352 --include-explain
```
```js
const { listReports, fetchReport } = require("daishin-report-search")
const latest = await listReports({ limit: 10 })
const semis = await listReports({ query: "반도체", limit: 5, maxInspect: 100 })
const withToken = await listReports({ githubToken: process.env.GITHUB_TOKEN })
const detail = await fetchReport("20260511082352", { includeExplain: true })
```
## 출력 필드
목록 항목은 `id`, `date`, `time`, `timestamp`, `title`, `headings`, `excerpt`, `ratingTargets`, `pageUrl`, `rawUrl`, `apiUrl`, `hasExplain`, `explainUrl`을 포함한다.
상세 조회는 원문 `text`를 추가하고, `includeExplain`이 켜져 있으면 `explain` 객체에 설명 페이지의 `title`, `headings`, `text`, `excerpt`, `pageUrl`을 포함한다.
## 주의 사항
- 투자 판단이나 매매 추천이 아니라 공개 리포트 조회 보조 기능이다.
- GitHub unauthenticated API rate limit, upstream repository 변경, HTML 구조 변경 시 경고나 오류가 반환될 수 있다. 목록 조회의 GitHub tree API가 403/429로 막히면 예외 대신 빈 `items``source.error`/rate-limit metadata를 반환한다.
- API limit을 높여야 할 때는 caller-owned `githubToken`/`githubHeaders` 옵션 또는 CLI 환경변수 `DAISHIN_GITHUB_TOKEN`/`GITHUB_TOKEN`을 사용할 수 있다. 이 값은 GitHub API host(tree discovery와 exact-file fallback)에만 전송되고 raw 원문 URL에는 전송되지 않는다. 기본 동작에는 토큰이나 proxy가 필요 없다.
- 상세 조회는 raw 원문 URL을 먼저 읽고, 실패하면 알려진 timestamp 경로의 GitHub contents API로 fallback한다.
- 검색어가 있으면 최신 파일부터 `maxInspect`개까지 원문을 읽어 매칭하므로 너무 낮게 잡으면 결과가 누락될 수 있다.

View file

@ -0,0 +1,32 @@
# 강남언니 병원 조회 가이드
`gangnamunni-clinic-search`는 강남언니 공개 검색 페이지에서 병원 후보를 조회하는 read-only 스킬입니다.
## 공개 접근 경로
- 검색 URL: `https://www.gangnamunni.com/search?q=<keyword>`
- 데이터 위치: HTML 안의 `__NEXT_DATA__` JSON (`props.pageProps.hospitals`)
- 인증/시크릿: 불필요
- 프록시: 사용하지 않음
## 예시
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({ query: "코성형", limit: 3 })
```
## 출력
각 후보는 공개 검색 페이지에 포함된 병원명, 평점, 리뷰 수, 지원 언어, 이미지 URL, 공개 병원 링크를 포함합니다.
## 제한사항
- 조회 시점 공개 검색 결과 기준입니다.
- 로그인, 상담, 예약, 결제, 찜, 리뷰 작성은 자동화하지 않습니다.
- CAPTCHA/차단/로그인벽/빈 shell 페이지는 실패 모드로 처리합니다.
- 의료 판단이나 병원 선택 보증을 대신하지 않습니다.

View file

@ -18,6 +18,7 @@ client/skill -> k-skill-proxy -> upstream public API
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/seoul-density/citydata` (서울 실시간 도시데이터 핫스팟 혼잡도/추정 인구, `SEOUL_OPEN_API_KEY`)
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` (생활쓰레기 배출정보, `DATA_GO_KR_API_KEY`; 쿼리 `pageNo`·`numOfRows` 필수, 값 `1`·`100`)
- `GET /v1/mfds/drug-safety/lookup` (식약처 의약품개요정보 + 안전상비의약품 정보, `DATA_GO_KR_API_KEY`)
@ -120,6 +121,14 @@ curl -fsS --get "${BASE}/v1/seoul-subway/arrival" \
--data-urlencode 'stationName=강남'
```
서울 실시간 혼잡도 endpoint:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
--data-urlencode 'area=강남역'
```
한국 날씨 endpoint:
```bash

View file

@ -0,0 +1,50 @@
# 국세청 사업자등록정보 진위확인 및 상태조회
`nts-business-registration` 스킬은 공공데이터포털의 **국세청_사업자등록정보 진위확인 및 상태조회 서비스**를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 사업자등록번호 상태조회: `POST /v1/nts-business/status`
- 사업자등록정보 진위확인: `POST /v1/nts-business/validate`
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다.
self-host 프록시를 쓰는 경우에만 `KSKILL_PROXY_BASE_URL`을 설정한다. 비우면 hosted proxy(`https://k-skill-proxy.nomadamas.org`)를 사용한다.
## 진위확인 개인정보 경로
`/v1/nts-business/validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 선택 주소/상호 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다. proxy는 validate 성공 응답을 캐시하지 않고(`status` 조회만 성공 캐시), 응답에 normalized `query`를 echo하지 않으며, upstream 응답이 요청값을 되돌려도 민감 필드를 제거한다.
기본 proxy 서버는 Fastify request logging을 켜지 않는다. self-host 운영자가 별도 요청 로깅을 활성화했다면 validate 요청 본문이 저장되지 않도록 로그 정책을 확인해야 한다. hosted proxy 대신 자체 운영 경로가 필요하면 `KSKILL_PROXY_BASE_URL`로 self-host proxy를 지정한다.
## 예시
```bash
python3 nts-business-registration/scripts/nts_business_registration.py status \
--b-no 123-45-67890
```
```bash
python3 nts-business-registration/scripts/nts_business_registration.py validate \
--business-json '{"b_no":"123-45-67890","start_dt":"2020-01-31","p_nm":"홍길동","b_nm":"테스트상사"}'
```
## 입력 제한
- 사업자등록번호는 숫자 10자리여야 한다. 하이픈은 자동 제거한다.
- 상태조회/진위확인은 한 번에 최대 100건까지 보낸다.
- 진위확인은 `b_no`, `start_dt`, `p_nm`이 필수다.
- 선택 필드: `p_nm2`, `b_nm`, `corp_no`, `b_sector`, `b_type`, `b_adr`
- 길이 제한: `p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자. `corp_no`는 제공 시 숫자 13자리여야 한다.
## 실패 모드
- `400 bad_request`: 입력 형식 오류 또는 필수 필드 누락
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음
- upstream 인증/활용신청 오류: 공공데이터포털 키가 해당 서비스에 승인되지 않았거나 오류 상태
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808>

View file

@ -0,0 +1,88 @@
# 서울 실시간 혼잡도 조회 가이드
## 이 기능으로 할 수 있는 일
- 서울 주요 121개 핫스팟의 실시간 혼잡도 단계(여유 / 보통 / 약간 붐빔 / 붐빔) 확인
- KT·SKT 통신 신호 기반 추정 인구 범위(`AREA_PPLTN_MIN ~ AREA_PPLTN_MAX`) 확인
- 기준 시각(`PPLTN_TIME`)과 혼잡도 메시지(`AREA_CONGEST_MSG`) 같이 확인
- 별도 사용자 `SEOUL_OPEN_API_KEY` 없이 `k-skill-proxy` 로 조회
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
## 기본 경로
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 로 요청한다.
사용자는 별도 서울 열린데이터 광장 OpenAPI key 를 직접 발급받을 필요는 없다. upstream key 는 proxy 서버에서만 `SEOUL_OPEN_API_KEY` 로 관리한다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 쓴다.
## 입력값
- `area` — 지원 장소명 (예: `강남역`, `홍대 관광특구`, `여의도한강공원`)
지원 장소 전체 목록은 `seoul-density/SKILL.md``AREAS` 카테고리 또는 다음 명령으로 확인한다:
```bash
python3 seoul-density/scripts/seoul_density.py list
```
## 기본 흐름
1. client/skill 은 기본 hosted path 또는 `KSKILL_PROXY_BASE_URL` 아래 `/v1/seoul-density/citydata` endpoint 를 호출한다.
2. proxy 는 서울 열린데이터 광장 `citydata_ppltn/1/1/{area}``SEOUL_OPEN_API_KEY` 와 함께 호출한다.
3. 응답을 그대로 돌려주며, `proxy.cache.hit` 메타데이터를 추가한다.
## 예시
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/seoul-density/citydata" \
--data-urlencode 'area=강남역'
```
스킬 CLI 사용 예시:
```bash
python3 seoul-density/scripts/seoul_density.py query "강남역"
```
예상 응답 (요약):
```json
{
"SeoulRtd.citydata_ppltn": [
{
"AREA_NM": "강남역",
"AREA_CONGEST_LVL": "약간 붐빔",
"AREA_PPLTN_MIN": "24000",
"AREA_PPLTN_MAX": "26000",
"PPLTN_TIME": "2026-05-14 09:30",
"AREA_CONGEST_MSG": "사람이 몰려있을 수 있어요"
}
],
"RESULT": { "RESULT.CODE": "INFO-000" }
}
```
## fallback / 대체 흐름
- `KSKILL_PROXY_BASE_URL` 을 별도로 넣으면 해당 proxy 를 우선 사용한다.
- 기본 hosted path 는 `https://k-skill-proxy.nomadamas.org/v1/seoul-density/citydata` 이다.
- self-host 운영자는 서버 쪽에만 `SEOUL_OPEN_API_KEY` 를 넣는다 (사용자 쪽에는 키가 필요 없다).
## 주의할 점
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
- 데이터는 호출 시점 기준 **약 15분 전** 값이며 5분 주기로 갱신된다.
- 새벽 01~05시는 실시간 데이터가 제공되지 않을 수 있다.
- 일일 호출 할당량 초과 시 다음 날 재시도해야 한다.
- 지원하지 않는 장소명을 넣으면 빈 응답이 돌아오므로 스킬의 `match` 서브커맨드로 후보를 먼저 확인한다.
## 참고 표면
- 공식 API 안내: `https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.do`
- 서울 열린데이터 광장: `https://data.seoul.go.kr`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

View file

@ -0,0 +1,145 @@
# 공연 일정·잔여석 조회 가이드
## 이 기능으로 할 수 있는 일
- YES24 (`ticket.yes24.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
- 인터파크 (`tickets.interpark.com`) 공연의 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회
- 공연 URL 또는 `platform:id` 표기 (`yes24:58026`, `interpark:26000541`) 로 입력
- 회차별 등급명·잔여수 (YES24 는 노출가 포함) 를 JSON 으로 정리
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- `python3` (3.9 이상) 와 `httpx` 패키지
- 인터넷 연결
`httpx` 설치:
```bash
pip install httpx
```
## v1 범위
이 기능은 **공개 endpoint / 조회 전용** 범위로 제공된다.
- YES24 의 `axPerfDay.aspx`, `axPerfPlayTime.aspx`, `axPerfRemainSeat.aspx` 와 인터파크의 `api-ticketfront.interpark.com/v1/goods/<id>/playSeq` 만 호출한다.
- 회차 단위 일정·등급별 잔여석 ** 만 정규화한다.
- 예매·결제·취소·환불·좌석 선택·로그인 자동화는 **의도적으로 포함하지 않는다**. 매크로를 이용한 입장권 부정구매·판매는 공연법 §4조의2 (2023.9.22 시행) 에 따라 형사처벌 대상이다.
- 차단 우회, CAPTCHA 우회, fingerprint spoofing, headless 감지 우회는 사용하지 않는다.
## 기본 흐름
1. 공연 URL 또는 `platform:id` 를 받아온다.
2. 일정만 필요하면 `schedule`, 등급별 잔여석까지 필요하면 `seats` 를 호출한다.
3. 결과 JSON 에서 회차별 날짜·시각·등급·잔여수를 정리하고 "조회 시각 기준" 임을 함께 안내한다.
4. 사용자가 페이지에서 직접 결제하도록 안내한다 — 스킬이 결제·예매 흐름을 대신하지 않는다.
## 예시
### 일정 조회 (인터파크)
```bash
python3 scripts/ticket_availability.py schedule "https://tickets.interpark.com/goods/26000541"
```
응답 (요약):
```json
{
"platform": "interpark",
"id": "26000541",
"schedule": [
{"date": "2026-05-13", "time": "14:30", "play_seq": "055"},
{"date": "2026-05-14", "time": "19:30", "play_seq": "057"}
]
}
```
### 일정 조회 (YES24, 기본 3주 윈도우)
```bash
python3 scripts/ticket_availability.py schedule "https://ticket.yes24.com/Perf/58026"
```
6개월 전체:
```bash
python3 scripts/ticket_availability.py schedule "yes24:58026" --all-dates
```
### 등급별 잔여석 조회
```bash
python3 scripts/ticket_availability.py seats "interpark:26000541"
```
응답 (요약, 회차당 1개 키):
```json
{
"platform": "interpark",
"id": "26000541",
"seats": {
"2026-05-13|14:30|055": {
"date": "2026-05-13", "time": "14:30", "play_seq": "055",
"seats": [
{"grade": "VIP석", "remain": 150},
{"grade": "R석", "remain": 36},
{"grade": "S석", "remain": 82},
{"grade": "A석", "remain": 71}
]
}
}
}
```
YES24 응답은 회차별 `time_label` (예: `1회`, `2회`) 와 등급별 `price` (노출가, 예: `110,000원`) 가 함께 들어온다.
### 헬스체크
```bash
python3 scripts/ticket_availability.py health
```
응답:
```json
{
"yes24": {"status": 200, "ok": true},
"interpark": {"status": 200, "ok": true}
}
```
### 한 줄 JSON (파이프용)
```bash
python3 scripts/ticket_availability.py seats "interpark:26000541" --compact
```
## 출력에서 확인할 점
- `platform``yes24` 또는 `interpark` 인지
- `schedule[].date`, `time` 또는 `time_label` 이 채워졌는지
- `seats[<key>].seats[].grade``remain` 이 채워졌는지
- 잔여 0 인 등급이 매진된 등급인지 (조회 시각 기준이라 실시간 변동 가능)
## 실패 모드
- **빈 `schedule`**: 공연 ID 가 유효하지만 향후 3주 (또는 6개월) 내 일정이 없을 때. `--all-dates` 또는 다른 ID 확인을 안내한다.
- **인터파크 `data: []`**: goods_code 가 지나간 공연이거나 오픈 전 / 비공개. 다른 ID 확인을 안내한다.
- **HTTP 4xx/5xx**: 차단·일시 장애. 우회 시도하지 않고 `http error` 메시지를 그대로 반환한다.
- **HTML 응답 스키마 변경**: YES24 `axPerfRemainSeat.aspx` 는 HTML 정규식 파싱이라 사이트 갱신 시 영향 가능. 잔여 0 으로 잘못 보고될 가능성이 있어 "조회 시각 기준" 임을 명시한다.
- **rate-limit**: `seats` 명령은 회차별로 순차 호출한다 (Interpark 0.3s, YES24 0.4s 간격). 100 회차 짜리 공연이면 30 ~ 40 초 소요. 짧은 모니터링 루프에 넣지 말 것.
## 보안·법적 주의
- 본 스킬은 **조회 전용** 이다. 시크릿·로그인 세션·자동 예매·자동 결제·좌석 선택을 일체 포함하지 않는다.
- 공연법 §4조의2 (2023.9.22 시행): 매크로 프로그램을 이용한 입장권 부정구매·판매는 형사처벌 대상. 이 스킬은 의도적으로 그 경로를 막아두었다.
- 등급별 잔여 *수치* 만 인용하고, 좌석 번호·좌석 위치는 노출하지 않는다.
## 참고
- v1 은 비로그인 / 공개 endpoint / 단일 HTTP 호출 범위다.
- 헤더는 `User-Agent` + `Referer` + JSON `Accept` 만 사용한다 (`Cookie`, `Authorization` 없음).
- `httpx` 외 외부 의존성은 없다.

View file

@ -66,6 +66,7 @@ npx --yes skills add <owner/repo> \
--skill real-estate-search \
--skill korean-scholarship-search \
--skill korean-stock-search \
--skill daishin-report-search \
--skill household-waste-info \
--skill mfds-drug-safety \
--skill mfds-food-safety \
@ -80,6 +81,7 @@ npx --yes skills add <owner/repo> \
--skill geeknews-search \
--skill daiso-product-search \
--skill market-kurly-search \
--skill gangnamunni-clinic-search \
--skill olive-young-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
@ -117,6 +119,7 @@ npx --yes skills add <owner/repo> \
--skill korean-patent-search \
--skill hipass-receipt \
--skill seoul-subway-arrival \
--skill seoul-density \
--skill subway-lost-property \
--skill geeknews-search \
--skill korea-weather \
@ -282,7 +285,7 @@ npm run ci
### Node 패키지
```bash
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search
npm install -g kordoc pdfjs-dist kbo-game kbl-results kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli court-auction-notice-search gongsijiga-search donation-place-search gangnamunni-clinic-search
export NODE_PATH="$(npm root -g)"
```
@ -360,6 +363,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
- `srt-booking`
- `ktx-booking`
- `seoul-subway-arrival`
- `seoul-density`
- `korea-weather`
- `fine-dust-location`
- `korean-law-search`

View file

@ -36,7 +36,7 @@ KAKAO_REST_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=
```
서울 지하철 도착정보 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 한국 날씨 조회는 `KSKILL_PROXY_BASE_URL` 이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 쓰므로 사용자 쪽 키가 불필요하다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크도 기본 hosted proxy를 쓴다. 생활쓰레기 배출정보는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`만 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다.
## Missing secret handling policy

View file

@ -86,6 +86,7 @@ bash scripts/check-setup.sh
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 서울 실시간 혼잡도 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`) |
| 한국 날씨 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`) |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
| 한강 수위 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
@ -103,6 +104,7 @@ bash scripts/check-setup.sh
- [시외버스 예매 가이드](features/intercity-bus-booking.md)
- [자연휴양림 빈 객실 조회 가이드](features/foresttrip-vacancy.md)
- [서울 지하철 도착정보 가이드](features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 가이드](features/seoul-density.md)
- [한국 날씨 조회 가이드](features/korea-weather.md)
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한강 수위 정보 가이드](features/han-river-water-level.md)

View file

@ -113,6 +113,8 @@
- 다이소몰 매장 픽업 재고: https://www.daisomall.co.kr/api/pd/pdh/selStrPkupStck (2026-05-05 기준 Unauthorized 차단 가능)
- 다이소몰 매장 픽업 가능 매장 목록: https://www.daisomall.co.kr/api/ms/msg/selPkupStr (특정 상품의 픽업 가능 매장 리스트, 매장 수량은 미제공)
- 다이소몰 온라인 재고: https://www.daisomall.co.kr/api/pdo/selOnlStck
- 강남언니 공개 검색: https://www.gangnamunni.com/search?q=<keyword>
- 강남언니 공개 병원 페이지: https://www.gangnamunni.com/hospitals/<id>
- 마켓컬리 검색 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
@ -130,6 +132,13 @@
- coupang_partners hosted fallback PR (merged): https://github.com/retention-corp/coupang_partners/pull/1
- bunjang-cli package: https://www.npmjs.com/package/bunjang-cli
- bunjang-cli repo: https://github.com/pinion05/bunjangcli
- 당근 메인: https://www.daangn.com/
- 당근 지역 검색 API: https://www.daangn.com/kr/api/v1/regions/keyword?keyword=<지역명>
- 당근 중고거래 검색 Remix data route: https://www.daangn.com/kr/buy-sell/all/?_data=routes/kr.buy-sell._index
- 당근부동산 검색 Remix data route: https://www.daangn.com/kr/realty/?_data=routes/kr.realty._index
- 당근알바 검색 Remix data route: https://www.daangn.com/kr/jobs/?_data=routes/kr.jobs._index
- 당근중고차 검색 Remix data route: https://www.daangn.com/kr/cars/?_data=routes/kr.cars._index
- 당근부동산 상세 페이지: https://realty.daangn.com/articles/<id>
- 블루리본 메인: https://www.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
@ -151,6 +160,7 @@
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 서울 실시간 도시데이터(`citydata_ppltn`): https://data.seoul.go.kr/dataList/OA-21778/A/1/datasetView.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

View file

@ -0,0 +1,123 @@
---
name: gangnamunni-clinic-search
description: 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 병원 링크를 조회한다.
license: MIT
metadata:
category: beauty
locale: ko-KR
phase: v1
---
# Gangnam Unni Clinic Search
## What this skill does
강남언니(Gangnam Unni) 웹 검색 페이지의 **비로그인 공개 Next.js payload**를 읽어 병원 후보를 조회한다.
- 키워드로 병원 후보를 검색한다.
- 공개 검색 결과에 포함된 평점, 평점 수, 리뷰 수, 지원 언어, 공개 이미지, 병원 링크를 정리한다.
- 예약, 상담, 결제, 리뷰 작성, 앱 로그인 등 사용자 계정이 필요한 액션은 하지 않는다.
## When to use
- "강남언니에서 강남 성형외과 찾아줘"
- "강남언니 병원 평점이랑 리뷰 수 봐줘"
- "코성형 병원 후보를 강남언니 기준으로 몇 개만 보여줘"
- "성형외과/피부과 병원 공개 링크를 찾아줘"
## When not to use
- 상담 신청, 예약, 결제, 병원 채팅, 찜 같은 계정 기반 액션이 필요한 경우
- 로그인 사용자에게만 보이는 이벤트, 가격, 개인화 추천을 확정해야 하는 경우
- 의료적 판단, 시술 적합성, 안전성 보증을 대신해야 하는 경우
## Prerequisites
- 인터넷 연결
- Node.js 18+
- 이 저장소의 `gangnamunni-clinic-search` package 또는 동일 로직
## Required inputs
### 1. Ask for a search keyword if it is missing
검색어가 없으면 먼저 확인한다.
- 권장 질문: `강남언니에서 찾을 병원/시술/지역 키워드를 알려주세요. 예: 강남 성형외과, 코성형, 피부과`
- 너무 넓으면: `검색어가 넓어요. 지역이나 시술명을 같이 주시면 후보를 더 좁힐 수 있어요.`
### 2. Keep the answer conservative
강남언니 공개 페이지 기준으로 확인한 후보임을 분명히 말한다. 병원 선택, 의료 조언, 수술 권유처럼 해석될 수 있는 표현은 피한다.
## Public Gangnam Unni surface
- search list: `https://www.gangnamunni.com/search?q=<keyword>`
- parsed payload: `<script id="__NEXT_DATA__" type="application/json">...props.pageProps.hospitals...</script>`
- public hospital URL: `https://www.gangnamunni.com/hospitals/<id>`
Discovery result: `curl`/Node fetch로 비로그인 검색 HTML이 200으로 응답하고, 병원 후보는 server-rendered `__NEXT_DATA__``props.pageProps.hospitals` 배열에 포함된다. 이 경로는 공개 read-only endpoint이므로 `k-skill-proxy`를 사용하지 않는다.
## Workflow
### 1. Search by keyword
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({ query: "강남 성형외과", limit: 5 })
console.log(result.items)
```
CLI:
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
### 2. Interpret returned fields
우선 아래 필드를 본다.
- `name`: 병원명
- `rating`, `ratingCount`, `reviewCount`: 공개 검색 페이지에 포함된 평점/리뷰 지표
- `languages`: 공개 지원 언어
- `url`: 강남언니 공개 병원 페이지
- `profileImage`, `mainImage`: 공개 이미지 URL
### 3. Fallback order
1. 기본: `https://www.gangnamunni.com/search?q=<keyword>``__NEXT_DATA__` payload를 파싱한다.
2. payload가 없으면 로그인벽, CAPTCHA, 차단, 빈 shell 페이지를 실패 모드로 분류한다.
3. 검색 결과가 너무 적거나 앱 전용 정보가 필요하면 자동화를 멈추고 사용자가 공식 앱/웹에서 직접 확인하도록 안내한다.
### 4. Respond safely
응답은 짧고 보수적으로 정리한다.
- 병원명
- 공개 평점/리뷰 수
- 지원 언어
- 강남언니 공개 링크
- `조회 시점 공개 검색 결과 기준이며, 의료 판단이나 실제 예약 가능 여부는 병원/공식 앱에서 확인해야 합니다.` 라고 명시한다.
## Done when
- 검색 키워드를 확인했다.
- 공개 검색 결과에서 병원 후보를 반환했거나, 실패 모드를 명확히 설명했다.
- 계정 기반 액션과 의료 판단은 하지 않았다.
## Failure modes
- 검색어가 너무 넓거나 강남언니가 병원 후보를 공개 payload에 일부만 넣을 수 있다.
- 강남언니 웹 구조가 바뀌면 `__NEXT_DATA__` 경로가 깨질 수 있다.
- 로그인 필요, CAPTCHA, 접근 차단, 빈 HTML shell은 자동 우회하지 않고 실패로 보고한다.
- 평점, 리뷰 수, 노출 순서는 시점에 따라 달라진다.
- 앱 전용/로그인 전용 정보는 비로그인 공개 조회만으로 확정할 수 없다.
## Notes
- 조회형 스킬이다.
- 비로그인 공개 표면 우선 원칙을 유지한다.
- 프록시와 API key는 사용하지 않는다.
- 의료 조언이나 병원 추천 보증이 아니라 공개 후보 정리로만 답한다.

View file

@ -125,6 +125,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 식품 안전 체크: 사용자 시크릿 불필요 (`DATA_GO_KR_API_KEY`와 선택적 `FOODSAFETYKOREA_API_KEY`는 proxy 서버만)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
- 서울 실시간 혼잡도: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `SEOUL_OPEN_API_KEY`)
- 한국 날씨: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KMA_OPEN_API_KEY`)
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`

View file

@ -0,0 +1,125 @@
---
name: nts-business-registration
description: 국세청 사업자등록정보 진위확인 및 사업자등록 상태조회를 공공데이터포털 API(k-skill-proxy 경유)로 수행한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 국세청 사업자등록정보 진위확인 및 상태조회
## What this skill does
공공데이터포털의 **국세청_사업자등록정보 진위확인 및 상태조회 서비스**를 `k-skill-proxy` 경유로 호출해 다음을 확인한다.
- `status`: 사업자등록번호 기준 상태조회 (`계속사업자`, `휴업자`, `폐업자`, 과세유형 등 upstream 응답 그대로 포함)
- `validate`: 사업자등록번호 + 개업일자 + 대표자명(및 선택 필드) 기준 진위확인
## When to use
- "이 사업자등록번호가 계속사업자인지 확인해줘"
- "사업자등록번호 상태조회해줘"
- "사업자등록번호, 개업일, 대표자명으로 진위확인해줘"
- 거래처 등록 전 공식 NTS/공공데이터포털 기준 확인이 필요할 때
## Prerequisites
- 인터넷 연결
- `python3`
- 설치된 skill payload 안에 `scripts/nts_business_registration.py` helper 포함
- hosted/self-host `k-skill-proxy``/v1/nts-business/status`, `/v1/nts-business/validate` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `국세청_사업자등록정보 진위확인 및 상태조회 서비스` 활용신청이 되어 있어야 한다.
## Validate privacy boundary
- `validate`는 대표자명(`p_nm`), 개업일자(`start_dt`), 주소·상호 같은 선택 메타데이터를 hosted proxy와 공공데이터포털 upstream으로 전송한다.
- hosted proxy는 `validate` 성공 응답을 캐시하지 않고, 프록시 `query` echo를 붙이지 않으며, upstream이 요청값을 되돌려도 민감 입력 필드를 응답에서 제거한다.
- 프록시의 기본 Fastify request logging은 꺼져 있다. 운영자가 별도 로그를 켠 self-host 환경에서는 요청 본문 로깅 정책을 직접 점검해야 한다.
- hosted proxy 경유가 부담스러운 진위확인 업무는 `KSKILL_PROXY_BASE_URL`로 직접 운영하는 self-host proxy를 지정한다.
## Official surfaces
- 공공데이터포털 문서: `https://www.data.go.kr/tcs/dss/selectApiDataDetailView.do?publicDataPk=15081808`
- 상태조회 upstream: `POST https://api.odcloud.kr/api/nts-businessman/v1/status?serviceKey=...`
- 진위확인 upstream: `POST https://api.odcloud.kr/api/nts-businessman/v1/validate?serviceKey=...`
- 프록시 route: `POST /v1/nts-business/status`, `POST /v1/nts-business/validate`
## Inputs
### 상태조회
- `b_no`: 사업자등록번호 10자리. 하이픈은 허용되며 helper/proxy가 숫자만 남긴다.
- 한 요청은 최대 100개까지 보낸다.
### 진위확인
필수:
- `b_no`: 사업자등록번호 10자리
- `start_dt`: 개업일자 `YYYYMMDD` (하이픈/점 허용)
- `p_nm`: 대표자 성명
선택:
- `p_nm2`: 대표자 성명2
- `b_nm`: 상호
- `corp_no`: 법인등록번호
- `b_sector`: 주업태명
- `b_type`: 주종목명
- `b_adr`: 사업장주소
텍스트 필드는 NTS 입력 규격에 맞춰 보수적으로 길이를 제한한다(`p_nm`/`p_nm2` 30자, `b_nm` 200자, `b_sector`/`b_type` 100자, `b_adr` 500자). `corp_no`는 제공할 경우 숫자 13자리여야 한다.
## Workflow
1. 사용자 입력에서 사업자등록번호는 숫자 10자리인지 확인한다.
2. 상태조회만 필요하면 `status`를 호출한다.
3. 진위확인은 최소 `b_no`, `start_dt`, `p_nm`이 있을 때만 호출한다.
4. 개인정보/거래처 정보는 필요한 필드만 보내고, 프록시 응답을 그대로 보존하되 핵심 상태/진위 결과를 짧게 요약한다.
5. upstream이 `upstream_not_configured`, 활용신청 미승인, 인증키 오류 등을 반환하면 설정/승인 문제로 안내한다.
## CLI examples
```bash
python3 scripts/nts_business_registration.py status \
--b-no 123-45-67890
```
```bash
python3 scripts/nts_business_registration.py validate \
--business-json '{"b_no":"123-45-67890","start_dt":"2020-01-31","p_nm":"홍길동","b_nm":"테스트상사"}'
```
## Direct proxy examples
```bash
curl -fsS -X POST "$KSKILL_PROXY_BASE_URL/v1/nts-business/status" \
-H 'content-type: application/json' \
-d '{"b_no":["123-45-67890"]}'
```
```bash
curl -fsS -X POST "$KSKILL_PROXY_BASE_URL/v1/nts-business/validate" \
-H 'content-type: application/json' \
-d '{"businesses":[{"b_no":"123-45-67890","start_dt":"20200131","p_nm":"홍길동"}]}'
```
## Failure modes
- `400 bad_request`: 사업자등록번호가 10자리가 아니거나 진위확인 필수 필드가 빠짐.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY`가 없음.
- upstream 인증/활용신청 오류: API 키가 해당 서비스에 승인되지 않았거나 만료/오류 상태.
- 빈 결과 또는 진위불일치: 공식 응답의 `valid`, `valid_msg`, `b_stt` 값을 그대로 근거로 설명한다.
## Done when
- 상태조회는 공식 응답의 `b_stt`, `b_stt_cd`, `tax_type` 등 핵심 필드를 확인했다.
- 진위확인은 `valid`, `valid_msg` 결과를 확인했다.
- API 키는 사용자에게 요구하지 않고 프록시 서버에만 둔다는 점을 지켰다.

View file

@ -0,0 +1,211 @@
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
BATCH_LIMIT = 100
VALIDATE_TEXT_FIELD_LIMITS = {
"p_nm": 30,
"p_nm2": 30,
"b_nm": 200,
"b_sector": 100,
"b_type": 100,
"b_adr": 500,
}
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 _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_business_number(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(b_no)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def normalize_start_date(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("개업일자(start_dt)를 YYYYMMDD 형식으로 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{8}", normalized):
raise ValueError("개업일자는 YYYYMMDD 형식이어야 합니다.")
try:
dt.date(int(normalized[:4]), int(normalized[4:6]), int(normalized[6:8]))
except ValueError as error:
raise ValueError("개업일자는 유효한 날짜여야 합니다.") from error
return normalized
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
text = _text_or_none(value)
if not text:
if required:
raise ValueError(f"{field_name}을(를) 입력하세요.")
return None
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
if max_length and len(text) > max_length:
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
return text
def normalize_corp_no(value: Any) -> str | None:
raw = _text_or_none(value)
if not raw:
return None
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{13}", normalized):
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
return normalized
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
numbers = [normalize_business_number(value) for value in business_numbers]
numbers = list(dict.fromkeys(numbers))
if not numbers:
raise ValueError("사업자등록번호를 1개 이상 입력하세요.")
if len(numbers) > BATCH_LIMIT:
raise ValueError("한 번에 조회할 수 있는 사업자등록번호는 100개까지입니다.")
return {"b_no": numbers}
def build_validate_business(**kwargs: Any) -> dict[str, str]:
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
business = {
"b_no": normalize_business_number(kwargs.get("b_no")),
"start_dt": normalize_start_date(kwargs.get("start_dt")),
"p_nm": p_nm,
}
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
value = normalize_validate_text(kwargs.get(key), key)
if value:
business[key] = value
corp_no = normalize_corp_no(kwargs.get("corp_no"))
if corp_no:
business["corp_no"] = corp_no
return business
def build_validate_payload(businesses: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
if not businesses:
raise ValueError("진위확인 대상 businesses를 1개 이상 입력하세요.")
if len(businesses) > BATCH_LIMIT:
raise ValueError("한 번에 진위확인할 수 있는 사업자는 100개까지입니다.")
return {"businesses": [build_validate_business(**business) for business in businesses]}
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(f"NTS business proxy request failed with HTTP {error.code}", status_code=error.code, url=getattr(error, "url", None)) from error
except urllib.error.URLError as error:
raise ApiError(f"NTS business proxy request failed: {error.reason}") from error
def _post_json(path: str, payload: dict[str, Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
resolved_base_url = resolve_proxy_base_url(base_url)
request = urllib.request.Request(
f"{resolved_base_url}{path}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "k-skill-nts-business-registration/1.0",
},
method="POST",
)
return read_json(request)
def query_status(business_numbers: list[Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/status", build_status_payload(business_numbers), base_url=base_url, read_json=read_json)
def validate_businesses(businesses: list[dict[str, Any]], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/validate", build_validate_payload(businesses), base_url=base_url, read_json=read_json)
def _parse_business_json(value: str) -> dict[str, Any]:
payload = json.loads(value)
if not isinstance(payload, dict):
raise argparse.ArgumentTypeError("business JSON must be an object")
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="NTS business registration status/authenticity helper")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status", help="사업자등록번호 상태조회")
status.add_argument("--b-no", action="append", required=True, help="사업자등록번호(10자리; 하이픈 허용). 여러 번 지정 가능")
status.add_argument("--proxy-base-url")
validate = subparsers.add_parser("validate", help="사업자등록정보 진위확인")
validate.add_argument("--business-json", action="append", type=_parse_business_json, required=True, help='예: {"b_no":"1234567890","start_dt":"20200101","p_nm":"홍길동"}')
validate.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "status":
print(json.dumps(query_status(args.b_no, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
if args.command == "validate":
print(json.dumps(validate_businesses(args.business_json, base_url=args.proxy_base_url), 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())

28
package-lock.json generated
View file

@ -612,6 +612,10 @@
"node": ">= 8"
}
},
"node_modules/daishin-report-search": {
"resolved": "packages/daishin-report-search",
"link": true
},
"node_modules/daiso-product-search": {
"resolved": "packages/daiso-product-search",
"link": true
@ -841,6 +845,10 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gangnamunni-clinic-search": {
"resolved": "packages/gangnamunni-clinic-search",
"link": true
},
"node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
@ -1755,6 +1763,16 @@
"rebrowser-playwright": ">=1.40.0"
}
},
"packages/daishin-report-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"daishin-report-search": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/daiso-product-search": {
"version": "0.2.0",
"license": "MIT",
@ -1769,6 +1787,16 @@
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"license": "MIT",
"bin": {
"gangnamunni-clinic-search": "src/cli.js"
},
"engines": {
"node": ">=18"
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"license": "MIT",

View file

@ -10,10 +10,10 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.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 scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py 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/nts_business_registration.py scripts/test_nts_business_registration.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 nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner 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_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",
"release:npm": "changeset publish"

View file

@ -0,0 +1,40 @@
# daishin-report-search
Public lookup client for timestamped Daishin Securities report HTML pages mirrored at `jay-jo-0/github_pages_repo`.
## Usage
```js
const { listReports, fetchReport } = require("daishin-report-search")
const latest = await listReports({ limit: 10 })
const filtered = await listReports({ query: "반도체", limit: 5, maxInspect: 100 })
const authenticated = await listReports({ githubToken: process.env.GITHUB_TOKEN })
const detail = await fetchReport("20260511082352", { includeExplain: true })
```
```bash
GITHUB_TOKEN=... daishin-report-search --limit 10
daishin-report-search --limit 10
daishin-report-search 반도체 --limit 5 --max-inspect 100
daishin-report-search --id 20260511082352 --include-explain
```
## Source path
- Tree: `https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1`
- Raw detail: `https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/<path>`
- Exact-file fallback: `https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/<path>?ref=main`
- Browser detail: `https://jay-jo-0.github.io/github_pages_repo/<path>`
No API key or proxy is required.
## Boundaries
- `limit` is normalized to a positive integer with a maximum of 50 results.
- `maxInspect` is normalized to a positive integer with a maximum of 500 latest pages to avoid excessive raw GitHub fetches.
- Invalid, zero, negative, or non-finite numeric options fall back to documented defaults.
- Latest/search discovery returns an empty result with `source.error` metadata instead of throwing when the GitHub tree API is blocked or rate-limited.
- Optional `githubToken` and `githubHeaders` options are forwarded only to `api.github.com` requests (tree discovery and exact-file contents fallback), not to raw detail requests. The CLI also honors `DAISHIN_GITHUB_TOKEN` or `GITHUB_TOKEN` from the environment.
- Exact report fetches try raw GitHub HTML first, then the GitHub contents API for the known timestamp path if raw fetch fails.
- The mirror can contain timestamped pages from sources other than Daishin Securities; inspect the returned title/headings/page URL before treating a result as Daishin-authored.

View file

@ -0,0 +1,36 @@
{
"name": "daishin-report-search",
"version": "0.1.0",
"description": "Public Daishin Securities report lookup client for GitHub Pages mirrored HTML reports",
"license": "MIT",
"main": "src/index.js",
"bin": {
"daishin-report-search": "src/cli.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",
"daishin",
"securities",
"research",
"reports",
"korea"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,48 @@
#!/usr/bin/env node
const { fetchReport, listReports } = require("./index")
async function main() {
const args = parseArgs(process.argv.slice(2))
const result = args.id
? await fetchReport(args.id, args)
: await listReports(args)
console.log(JSON.stringify(result, null, 2))
}
function parseArgs(argv) {
const options = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--query" || arg === "-q") options.query = argv[++i] || ""
else if (arg === "--limit") options.limit = argv[++i]
else if (arg === "--max-inspect") options.maxInspect = argv[++i]
else if (arg === "--id") options.id = argv[++i]
else if (arg === "--include-explain") options.includeExplain = true
else if (arg === "--include-html") options.includeHtml = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (/^\d{14}(?:\.html)?$/.test(arg) && !options.id) {
options.id = arg
} else if (!options.query) {
options.query = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: daishin-report-search [query] [options]\n\nList latest reports:\n daishin-report-search --limit 10\n daishin-report-search 반도체 --limit 5 --max-inspect 100\n\nFetch one report:\n daishin-report-search --id 20260511082352 --include-explain\n\nOptions:\n -q, --query <text> Filter by title/headings/detail text\n --limit <number> Maximum list results (default: 10)\n --max-inspect <n> Maximum latest pages to inspect for query matching\n --id <timestamp> Fetch one YYYYMMDDHHMMSS report\n --include-explain Fetch companion *_explain.html page for --id\n --include-html Include raw HTML in JSON output\n`)
console.log("Environment:\n DAISHIN_GITHUB_TOKEN or GITHUB_TOKEN Optional caller-owned token for api.github.com requests\n")
}
function run() {
return main().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, main }

View file

@ -0,0 +1,441 @@
const OWNER = "Jay-jo-0"
const API_OWNER = "jay-jo-0"
const REPO = "github_pages_repo"
const BRANCH = "main"
const PAGES_BASE_URL = "https://jay-jo-0.github.io/github_pages_repo"
const RAW_BASE_URL = `https://raw.githubusercontent.com/${OWNER}/${REPO}/${BRANCH}`
const API_BASE_URL = `https://api.github.com/repos/${API_OWNER}/${REPO}`
const TREE_URL = `${API_BASE_URL}/git/trees/${BRANCH}?recursive=1`
const REPORT_PATH_PATTERN = /^(\d{14})(?:_explain)?\.html$/
const DEFAULT_LIMIT = 10
const MAX_LIMIT = 50
const DEFAULT_MAX_INSPECT = 50
const MAX_INSPECT = 500
async function listReports(options = {}) {
const {
query = "",
limit = 10,
maxInspect,
includeHtml = false,
fetcher = global.fetch
} = options
if (!fetcher) throw new Error("fetch is required")
const normalizedLimit = parsePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
const normalizedQuery = String(query || "").trim()
const defaultInspectBudget = Math.max(DEFAULT_MAX_INSPECT, normalizedLimit * 5)
const normalizedMaxInspect = parsePositiveInteger(maxInspect, defaultInspectBudget, MAX_INSPECT)
const inspectBudget = Math.max(normalizedLimit, normalizedMaxInspect)
const warnings = []
let tree
try {
tree = await fetchJson(fetcher, TREE_URL, options)
} catch (error) {
warnings.push(`GitHub tree discovery failed: ${error.message}`)
return {
query: normalizedQuery,
count: 0,
items: [],
warnings,
source: buildSource(0, 0, error)
}
}
if (tree.truncated) warnings.push("github tree response was truncated; latest report list may be incomplete")
const paths = Array.isArray(tree.tree)
? tree.tree.filter((entry) => entry && entry.type === "blob").map((entry) => entry.path)
: []
const candidates = parseTreePaths(paths)
const items = []
let inspectedReports = 0
for (const candidate of candidates.slice(0, inspectBudget)) {
let item = { ...candidate, ...buildReportUrls(candidate.path) }
if (candidate.hasExplain) {
item.explainUrl = buildReportUrls(candidate.explainPath).pageUrl
item.explainRawUrl = buildReportUrls(candidate.explainPath).rawUrl
}
try {
inspectedReports += 1
const html = await fetchText(fetcher, item.rawUrl, options)
const parsed = parseReportHtml(html)
item = {
...item,
title: parsed.title || item.id,
headings: parsed.headings,
excerpt: parsed.excerpt,
ratingTargets: parsed.ratingTargets
}
if (includeHtml) item.html = html
if (matchesQuery({ ...item, text: parsed.text }, normalizedQuery)) items.push(item)
} catch (error) {
warnings.push(`report detail failed for ${item.path}: ${error.message}`)
if (!normalizedQuery) items.push({ ...item, title: item.id })
}
if (items.length >= normalizedLimit) break
}
if (items.length < normalizedLimit && candidates.length > inspectBudget) {
warnings.push(`inspection budget exhausted after ${inspectBudget} of ${candidates.length} report pages`)
}
return {
query: normalizedQuery,
count: items.length,
items,
warnings,
source: buildSource(candidates.length, inspectedReports)
}
}
async function fetchReport(idOrPath, options = {}) {
const { includeExplain = false, includeHtml = false, fetcher = global.fetch } = options
if (!fetcher) throw new Error("fetch is required")
const path = normalizeReportPath(idOrPath)
const meta = parseTimestamp(path)
if (!meta || meta.isExplain) throw new Error(`invalid report id or path: ${idOrPath}`)
const urls = buildReportUrls(path)
const html = await fetchReportHtml(fetcher, urls, options)
const parsed = parseReportHtml(html)
const report = {
...meta,
...urls,
title: parsed.title || meta.id,
headings: parsed.headings,
text: parsed.text,
excerpt: parsed.excerpt,
ratingTargets: parsed.ratingTargets
}
if (includeHtml) report.html = html
if (includeExplain) {
const explainPath = `${meta.id}_explain.html`
const explainUrls = buildReportUrls(explainPath)
try {
const explainHtml = await fetchReportHtml(fetcher, explainUrls, options)
const explainParsed = parseReportHtml(explainHtml)
report.explain = {
...parseTimestamp(explainPath),
...explainUrls,
title: explainParsed.title || `${meta.id} explanation`,
headings: explainParsed.headings,
text: explainParsed.text,
excerpt: explainParsed.excerpt,
ratingTargets: explainParsed.ratingTargets
}
if (includeHtml) report.explain.html = explainHtml
} catch (error) {
report.explain = null
report.warnings = [`explanation page failed for ${explainPath}: ${error.message}`]
}
}
return report
}
function parseTreePaths(paths) {
const byId = new Map()
for (const path of paths) {
const meta = parseTimestamp(path)
if (!meta) continue
const record = byId.get(meta.id) || { id: meta.id }
if (meta.isExplain) {
record.explainPath = meta.path
record.hasExplain = true
} else {
Object.assign(record, meta)
record.hasExplain = Boolean(record.hasExplain)
}
byId.set(meta.id, record)
}
return [...byId.values()]
.filter((record) => record.path)
.map((record) => ({ ...record, hasExplain: Boolean(record.explainPath) }))
.sort((a, b) => b.id.localeCompare(a.id))
}
function parseTimestamp(path) {
const match = String(path || "").match(REPORT_PATH_PATTERN)
if (!match) return null
const id = match[1]
const isExplain = String(path).includes("_explain.html")
const year = id.slice(0, 4)
const month = id.slice(4, 6)
const day = id.slice(6, 8)
const hour = id.slice(8, 10)
const minute = id.slice(10, 12)
const second = id.slice(12, 14)
const timestamp = `${year}-${month}-${day}T${hour}:${minute}:${second}+09:00`
return {
id,
path: String(path),
date: `${year}-${month}-${day}`,
time: `${hour}:${minute}:${second}`,
timestamp,
epochMs: Date.parse(timestamp),
isExplain
}
}
function buildReportUrls(path, options = {}) {
const branch = options.branch || BRANCH
const encodedPath = encodeReportPath(path)
return {
pageUrl: `${PAGES_BASE_URL}/${encodedPath}`,
rawUrl: `https://raw.githubusercontent.com/${OWNER}/${REPO}/${branch}/${encodedPath}`,
apiUrl: `${API_BASE_URL}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`
}
}
function parseReportHtml(html) {
const withoutScripts = String(html || "")
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
const title = firstText(withoutScripts, /<h1\b[^>]*>([\s\S]*?)<\/h1>/i)
|| firstText(withoutScripts, /<title\b[^>]*>([\s\S]*?)<\/title>/i)
const headings = [...withoutScripts.matchAll(/<h[1-3]\b[^>]*>([\s\S]*?)<\/h[1-3]>/gi)]
.map((match) => normalizeText(stripTags(match[1])))
.filter(Boolean)
const ratingTargets = parseTables(withoutScripts).filter((row) => {
const keys = Object.keys(row).join(" ")
return /종목명|투자의견|목표주가|Rating|Target/i.test(keys)
})
const text = normalizeText(
decodeEntities(
withoutScripts
.replace(/<\/?(p|div|br|li|tr|h[1-6]|table|thead|tbody|ul|ol)\b[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, " ")
)
)
const excerpt = text.length > 300 ? `${text.slice(0, 297)}...` : text
return { title, headings, text, excerpt, ratingTargets }
}
function parseTables(html) {
const rows = []
for (const tableMatch of String(html || "").matchAll(/<table\b[^>]*>([\s\S]*?)<\/table>/gi)) {
const tableRows = [...tableMatch[1].matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map((rowMatch) =>
[...rowMatch[1].matchAll(/<t[hd]\b[^>]*>([\s\S]*?)<\/t[hd]>/gi)].map((cellMatch) => normalizeText(stripTags(cellMatch[1])))
).filter((cells) => cells.length > 0)
if (tableRows.length < 2) continue
const headers = tableRows[0]
for (const cells of tableRows.slice(1)) {
const row = {}
headers.forEach((header, index) => {
if (header && cells[index]) row[header] = cells[index]
})
if (Object.keys(row).length > 0) rows.push(row)
}
}
return rows
}
function matchesQuery(item, query) {
if (!query) return true
const haystack = [item.id, item.title, item.excerpt, item.text, ...(item.headings || [])]
.join("\n")
.toLocaleLowerCase("ko-KR")
return query.toLocaleLowerCase("ko-KR").split(/\s+/).filter(Boolean).every((term) => haystack.includes(term))
}
async function fetchJson(fetcher, url, options = {}) {
const response = await fetcher(url, { headers: requestHeaders(url, options) })
await assertOk(response, url)
if (typeof response.json === "function") return response.json()
return JSON.parse(await response.text())
}
async function fetchText(fetcher, url, options = {}) {
const response = await fetcher(url, { headers: requestHeaders(url, options) })
await assertOk(response, url)
return response.text()
}
async function fetchReportHtml(fetcher, urls, options = {}) {
try {
return await fetchText(fetcher, urls.rawUrl, options)
} catch (rawError) {
try {
const contents = await fetchJson(fetcher, urls.apiUrl, options)
return decodeContentsApiHtml(contents, urls.apiUrl)
} catch (contentsError) {
const error = new Error(`${rawError.message}; contents fallback failed: ${contentsError.message}`)
error.cause = rawError
error.fallbackCause = contentsError
error.url = rawError.url
error.status = rawError.status
error.statusText = rawError.statusText
error.kind = rawError.kind
error.rateLimit = rawError.rateLimit
throw error
}
}
}
async function assertOk(response, url) {
if (response && response.ok) return
const statusCode = response && response.status
const statusText = response && response.statusText
const status = response ? `${statusCode || ""} ${statusText || ""}`.trim() : "no response"
const error = new Error(`HTTP ${status} for ${url}`)
error.url = url
error.status = statusCode || null
error.statusText = statusText || ""
error.kind = statusCode === 403 || statusCode === 429 ? "rate_limit" : "http"
error.rateLimit = readRateLimit(response && response.headers)
throw error
}
function requestHeaders(url, options = {}) {
const headers = {
"user-agent": "k-skill daishin-report-search (+https://github.com/NomaDamas/k-skill)",
accept: "application/vnd.github+json, text/html;q=0.9, */*;q=0.8"
}
if (isGitHubApiUrl(url)) {
Object.assign(headers, options.githubHeaders || {})
const token = options.githubToken || readEnvToken()
if (token && !hasHeader(headers, "authorization")) headers.authorization = `Bearer ${token}`
}
return headers
}
function decodeContentsApiHtml(contents, url) {
if (!contents || typeof contents.content !== "string") {
throw new Error(`GitHub contents response missing content for ${url}`)
}
if (contents.encoding && contents.encoding !== "base64") {
throw new Error(`unsupported GitHub contents encoding ${contents.encoding} for ${url}`)
}
return Buffer.from(contents.content.replace(/\s+/g, ""), "base64").toString("utf8")
}
function isGitHubApiUrl(url) {
try {
return new URL(url).hostname.toLowerCase() === "api.github.com"
} catch {
return false
}
}
function buildSource(totalReportsDiscovered, inspectedReports, error) {
const source = {
treeUrl: TREE_URL,
pagesBaseUrl: PAGES_BASE_URL,
rawBaseUrl: RAW_BASE_URL,
branch: BRANCH,
totalReportsDiscovered,
inspectedReports
}
if (error) source.error = serializeSourceError(error)
return source
}
function serializeSourceError(error) {
return {
message: error.message,
url: error.url || TREE_URL,
status: error.status || null,
statusText: error.statusText || "",
kind: error.kind || "unknown",
rateLimit: error.rateLimit || {}
}
}
function readRateLimit(headers) {
if (!headers || typeof headers.get !== "function") return {}
const reset = headers.get("x-ratelimit-reset")
const retryAfter = headers.get("retry-after")
const rateLimit = {
limit: headers.get("x-ratelimit-limit") || "",
remaining: headers.get("x-ratelimit-remaining") || "",
reset: reset || "",
retryAfter: retryAfter || ""
}
if (reset && /^\d+$/.test(reset)) rateLimit.resetAt = new Date(Number(reset) * 1000).toISOString()
return rateLimit
}
function readEnvToken() {
if (typeof process === "undefined" || !process.env) return ""
return process.env.DAISHIN_GITHUB_TOKEN || process.env.GITHUB_TOKEN || ""
}
function hasHeader(headers, name) {
const normalized = name.toLowerCase()
return Object.keys(headers).some((key) => key.toLowerCase() === normalized)
}
function normalizeReportPath(idOrPath) {
const value = String(idOrPath || "").trim()
if (/^\d{14}$/.test(value)) return `${value}.html`
return value.replace(/^\/+/, "")
}
function firstText(html, pattern) {
const match = String(html || "").match(pattern)
return match ? normalizeText(stripTags(match[1])) : ""
}
function stripTags(value) {
return decodeEntities(String(value || "").replace(/<[^>]+>/g, " "))
}
function normalizeText(value) {
return String(value || "").replace(/\s+/g, " ").trim()
}
function parsePositiveInteger(value, defaultValue, maxValue) {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return defaultValue
const integer = Math.floor(parsed)
if (integer <= 0) return defaultValue
return Math.min(integer, maxValue)
}
function decodeEntities(value) {
const named = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " "
}
return String(value || "")
.replace(/&#(\d+);/g, (entity, code) => decodeCodePoint(Number(code), entity))
.replace(/&#x([0-9a-f]+);/gi, (entity, code) => decodeCodePoint(Number.parseInt(code, 16), entity))
.replace(/&([a-z]+);/gi, (_, name) => named[name.toLowerCase()] || `&${name};`)
}
function decodeCodePoint(codePoint, originalEntity) {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return originalEntity
return String.fromCodePoint(codePoint)
}
function encodeReportPath(path) {
return String(path || "").split("/").map(encodeURIComponent).join("/")
}
module.exports = {
API_BASE_URL,
BRANCH,
PAGES_BASE_URL,
RAW_BASE_URL,
TREE_URL,
buildReportUrls,
fetchReport,
listReports,
parseReportHtml,
parseTimestamp,
parseTreePaths
}

View file

@ -0,0 +1,364 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const {
buildReportUrls,
fetchReport,
listReports,
parseReportHtml,
parseTimestamp,
parseTreePaths
} = require("../src/index")
const { parseArgs } = require("../src/cli")
const TREE_URL = "https://api.github.com/repos/jay-jo-0/github_pages_repo/git/trees/main?recursive=1"
function jsonResponse(value, ok = true, responseOptions = {}) {
const headers = responseOptions.headers || {}
const getHeader = (name) => {
const normalized = String(name).toLowerCase()
if (headers[normalized]) return headers[normalized]
return normalized === "content-type" && ok ? "application/json" : null
}
return {
ok,
status: responseOptions.status || (ok ? 200 : 500),
statusText: responseOptions.statusText || (ok ? "OK" : "Server Error"),
headers: { get: getHeader },
text: async () => JSON.stringify(value),
json: async () => value
}
}
function textResponse(value, ok = true) {
return {
ok,
status: ok ? 200 : 404,
statusText: ok ? "OK" : "Not Found",
headers: { get: () => "text/html; charset=utf-8" },
text: async () => value
}
}
function timestampPath(prefix, index) {
const day = String((index % 28) + 1).padStart(2, "0")
const hour = String(Math.floor(index / 28) % 24).padStart(2, "0")
const minute = String(Math.floor(index / (28 * 24)) % 60).padStart(2, "0")
const second = String(index % 60).padStart(2, "0")
return `${prefix}${day}${hour}${minute}${second}.html`
}
test("parseTimestamp parses timestamp filenames into ISO-like metadata", () => {
assert.deepEqual(parseTimestamp("20260511082352.html"), {
id: "20260511082352",
path: "20260511082352.html",
date: "2026-05-11",
time: "08:23:52",
timestamp: "2026-05-11T08:23:52+09:00",
epochMs: Date.parse("2026-05-10T23:23:52.000Z"),
isExplain: false
})
assert.equal(parseTimestamp("20260511082352_explain.html").isExplain, true)
assert.equal(parseTimestamp("README.md"), null)
})
test("parseTreePaths filters timestamp reports and pairs explanation pages", () => {
const reports = parseTreePaths([
"nested/ignored.html",
"20260511082352.html",
"20260511082352_explain.html",
"20260512010102_explain.html",
"20260512010102.html",
"README.md"
])
assert.deepEqual(reports.map((report) => report.id), ["20260512010102", "20260511082352"])
assert.equal(reports[0].explainPath, "20260512010102_explain.html")
assert.equal(reports[1].hasExplain, true)
})
test("buildReportUrls returns GitHub Pages, raw, and API URLs", () => {
assert.deepEqual(buildReportUrls("20260511082352.html"), {
pageUrl: "https://jay-jo-0.github.io/github_pages_repo/20260511082352.html",
rawUrl: "https://raw.githubusercontent.com/Jay-jo-0/github_pages_repo/main/20260511082352.html",
apiUrl: "https://api.github.com/repos/jay-jo-0/github_pages_repo/contents/20260511082352.html?ref=main"
})
})
test("parseReportHtml extracts title, headings, text, rating table, and excerpt", () => {
const parsed = parseReportHtml(`<!doctype html><html><head><title>[대신증권 류형근] 반도체업</title></head>
<body><h1>[대신증권 류형근] [Issue & News] 반도체업: 새로운 역사</h1>
<h2>반도체, 올라갑니다</h2><p> SK .</p>
<table><tr><th>종목명</th><th></th><th></th></tr><tr><td></td><td>Buy</td><td>450,000</td></tr></table></body></html>`)
assert.equal(parsed.title, "[대신증권 류형근] [Issue & News] 반도체업: 새로운 역사")
assert.deepEqual(parsed.headings, ["[대신증권 류형근] [Issue & News] 반도체업: 새로운 역사", "반도체, 더 올라갑니다"])
assert.match(parsed.text, /삼성전자와 SK하이닉스/)
assert.deepEqual(parsed.ratingTargets, [{ 종목명: "삼성전자", 투자의견: "Buy", 목표주가: "450,000원" }])
assert.ok(parsed.excerpt.length <= 300)
})
test("listReports reads the GitHub tree, sorts latest first, fetches selected titles, and preserves warnings", async () => {
const calls = []
const fetcher = async (url) => {
calls.push(url)
if (url === TREE_URL) {
return jsonResponse({
truncated: false,
tree: [
{ path: "20260511082352.html", type: "blob" },
{ path: "20260511082352_explain.html", type: "blob" },
{ path: "20260514074108.html", type: "blob" },
{ path: "assets/logo.png", type: "blob" }
]
})
}
if (url.endsWith("20260514074108.html")) return textResponse("<h1>[JAEMINI] 미국 장마감 시황 26.05.14</h1><p>시장 요약</p>")
if (url.endsWith("20260511082352.html")) return textResponse("<h1>[대신증권 류형근] 반도체업</h1><p>반도체 리포트</p>")
throw new Error(`unexpected url ${url}`)
}
const result = await listReports({ limit: 2, fetcher })
assert.equal(result.source.treeUrl, TREE_URL)
assert.equal(result.items.length, 2)
assert.deepEqual(result.items.map((item) => item.id), ["20260514074108", "20260511082352"])
assert.equal(result.items[0].title, "[JAEMINI] 미국 장마감 시황 26.05.14")
assert.equal(result.items[1].hasExplain, true)
assert.equal(result.items[1].explainUrl, "https://jay-jo-0.github.io/github_pages_repo/20260511082352_explain.html")
assert.equal(result.warnings.length, 0)
assert.ok(calls.some((url) => url.includes("git/trees/main?recursive=1")))
})
test("listReports can query detail text beyond the first page until it finds matches", async () => {
const fetcher = async (url) => {
if (url === TREE_URL) {
return jsonResponse({
tree: [
{ path: "20260514074108.html", type: "blob" },
{ path: "20260511082352.html", type: "blob" }
]
})
}
if (url.endsWith("20260514074108.html")) return textResponse("<h1>미국 장마감 시황</h1><p>시장</p>")
if (url.endsWith("20260511082352.html")) return textResponse("<h1>[대신증권 류형근] 반도체업</h1><p>삼성전자 목표주가 상향</p>")
throw new Error(`unexpected url ${url}`)
}
const result = await listReports({ query: "삼성전자", limit: 1, maxInspect: 2, fetcher })
assert.deepEqual(result.items.map((item) => item.id), ["20260511082352"])
assert.equal(result.query, "삼성전자")
})
test("listReports clamps non-finite and huge numeric options before inspecting reports", async () => {
const detailCalls = []
const tree = Array.from({ length: 600 }, (_, index) => ({ path: timestampPath("202605", index), type: "blob" }))
const fetcher = async (url) => {
if (url === TREE_URL) return jsonResponse({ tree })
detailCalls.push(url)
return textResponse("<h1>시장 요약</h1><p>일반 내용</p>")
}
const result = await listReports({ query: "없는검색어", limit: Infinity, maxInspect: 1e9, fetcher })
assert.equal(result.count, 0)
assert.equal(detailCalls.length, 500)
assert.equal(result.source.inspectedReports, 500)
assert.match(result.warnings.at(-1), /inspection budget exhausted after 500 of 600 report pages/)
const hugeLimitResult = await listReports({ limit: 1e9, fetcher })
assert.equal(hugeLimitResult.items.length, 50)
})
test("listReports falls back to defaults for invalid, zero, and negative numeric options", async () => {
const detailCalls = []
const tree = Array.from({ length: 60 }, (_, index) => ({ path: timestampPath("202604", index), type: "blob" }))
const fetcher = async (url) => {
if (url === TREE_URL) return jsonResponse({ tree })
detailCalls.push(url)
return textResponse("<h1>시장 요약</h1><p>일반 내용</p>")
}
const result = await listReports({ query: "없는검색어", limit: Number.NaN, maxInspect: -25, fetcher })
assert.equal(result.count, 0)
assert.equal(detailCalls.length, 50)
assert.equal(result.source.inspectedReports, 50)
const zeroLimit = await listReports({ limit: 0, maxInspect: 0, fetcher })
assert.equal(zeroLimit.items.length, 10)
})
test("parseArgs preserves numeric option text for library validation", () => {
assert.deepEqual(parseArgs(["--limit", "Infinity", "--max-inspect", "1e9"]), {
limit: "Infinity",
maxInspect: "1e9"
})
})
test("parseReportHtml preserves malformed numeric entities instead of throwing", () => {
const parsed = parseReportHtml("<h1>&#999999999999; &#x110000; &#65; &#x41;</h1><p>본문</p>")
assert.match(parsed.title, /&#999999999999;/)
assert.match(parsed.title, /&#x110000;/)
assert.match(parsed.title, /A A/)
assert.match(parsed.text, /본문/)
})
test("listReports returns structured source errors for GitHub tree rate limits", async () => {
const reset = String(Math.floor(Date.parse("2026-05-14T01:00:00Z") / 1000))
const fetcher = async (url) => {
assert.equal(url, TREE_URL)
return jsonResponse(
{ message: "API rate limit exceeded" },
false,
{
status: 403,
statusText: "rate limit exceeded",
headers: {
"x-ratelimit-limit": "60",
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": reset
}
}
)
}
const result = await listReports({ limit: 3, fetcher })
assert.equal(result.count, 0)
assert.deepEqual(result.items, [])
assert.equal(result.source.totalReportsDiscovered, 0)
assert.equal(result.source.inspectedReports, 0)
assert.equal(result.source.error.status, 403)
assert.equal(result.source.error.kind, "rate_limit")
assert.equal(result.source.error.rateLimit.limit, "60")
assert.equal(result.source.error.rateLimit.remaining, "0")
assert.equal(result.source.error.rateLimit.reset, reset)
assert.equal(result.source.error.rateLimit.resetAt, "2026-05-14T01:00:00.000Z")
assert.match(result.warnings[0], /GitHub tree discovery failed: HTTP 403 rate limit exceeded/)
})
test("listReports classifies GitHub 429 responses as structured rate limits", async () => {
const fetcher = async () => jsonResponse(
{ message: "Too Many Requests" },
false,
{
status: 429,
statusText: "Too Many Requests",
headers: { "retry-after": "42" }
}
)
const result = await listReports({ limit: 1, fetcher })
assert.equal(result.count, 0)
assert.equal(result.source.error.status, 429)
assert.equal(result.source.error.kind, "rate_limit")
assert.equal(result.source.error.rateLimit.retryAfter, "42")
assert.match(result.warnings[0], /GitHub tree discovery failed: HTTP 429 Too Many Requests/)
})
test("listReports sends caller GitHub headers and token to discovery requests", async () => {
const calls = []
const fetcher = async (url, init = {}) => {
calls.push({ url, headers: init.headers })
if (url === TREE_URL) return jsonResponse({ tree: [{ path: "20260511082352.html", type: "blob" }] })
return textResponse("<h1>헤더 테스트</h1><p>본문</p>")
}
const result = await listReports({
limit: 1,
githubToken: "test-token",
githubHeaders: { "x-github-api-version": "2022-11-28" },
fetcher
})
assert.equal(result.items.length, 1)
assert.equal(calls[0].headers.authorization, "Bearer test-token")
assert.equal(calls[0].headers["x-github-api-version"], "2022-11-28")
assert.equal(calls[1].headers.authorization, undefined)
assert.equal(calls[1].headers["x-github-api-version"], undefined)
})
test("fetchReport does not forward caller GitHub auth to raw detail requests", async () => {
const calls = []
const fetcher = async (url, init = {}) => {
calls.push({ url, headers: init.headers })
return textResponse("<h1>권한 범위 테스트</h1><p>본문</p>")
}
const report = await fetchReport("20260511082352", {
githubToken: "test-token",
githubHeaders: { "x-github-api-version": "2022-11-28" },
fetcher
})
assert.equal(report.title, "권한 범위 테스트")
assert.equal(calls.length, 1)
assert.match(calls[0].url, /raw\.githubusercontent\.com/)
assert.equal(calls[0].headers.authorization, undefined)
assert.equal(calls[0].headers["x-github-api-version"], undefined)
})
test("fetchReport falls back to GitHub contents API when raw exact report fetch fails", async () => {
const calls = []
const html = Buffer.from("<h1>콘텐츠 API 원문</h1><p>fallback body</p>", "utf8").toString("base64")
const fetcher = async (url, init = {}) => {
calls.push({ url, headers: init.headers })
if (url.includes("raw.githubusercontent.com")) {
return textResponse("not found", false)
}
if (url.includes("/contents/20260511082352.html")) {
return jsonResponse({ content: html, encoding: "base64" })
}
throw new Error(`unexpected url ${url}`)
}
const report = await fetchReport("20260511082352", {
githubToken: "test-token",
githubHeaders: { "x-github-api-version": "2022-11-28" },
fetcher
})
assert.equal(report.title, "콘텐츠 API 원문")
assert.match(report.text, /fallback body/)
assert.match(calls[0].url, /raw\.githubusercontent\.com/)
assert.equal(calls[0].headers.authorization, undefined)
assert.match(calls[1].url, /api\.github\.com/)
assert.equal(calls[1].headers.authorization, "Bearer test-token")
assert.equal(calls[1].headers["x-github-api-version"], "2022-11-28")
})
test("listReports reports the actual number of inspected detail pages", async () => {
const detailCalls = []
const tree = Array.from({ length: 10 }, (_, index) => ({ path: timestampPath("202603", index), type: "blob" }))
const fetcher = async (url) => {
if (url === TREE_URL) return jsonResponse({ tree })
detailCalls.push(url)
return textResponse("<h1>시장 요약</h1><p>일반 내용</p>")
}
const result = await listReports({ limit: 2, fetcher })
assert.equal(result.items.length, 2)
assert.equal(detailCalls.length, 2)
assert.equal(result.source.inspectedReports, 2)
})
test("fetchReport returns detail plus optional explanation page", async () => {
const fetcher = async (url) => {
if (url.endsWith("20260511082352.html")) return textResponse("<h1>원문 리포트</h1><p>원문 내용</p>")
if (url.endsWith("20260511082352_explain.html")) return textResponse("<h1>쉬운 설명</h1><p>설명 내용</p>")
throw new Error(`unexpected url ${url}`)
}
const report = await fetchReport("20260511082352", { includeExplain: true, fetcher })
assert.equal(report.id, "20260511082352")
assert.equal(report.title, "원문 리포트")
assert.equal(report.explain.title, "쉬운 설명")
assert.match(report.text, /원문 내용/)
assert.match(report.explain.text, /설명 내용/)
})

View file

@ -0,0 +1,35 @@
# gangnamunni-clinic-search
Public Gangnam Unni clinic lookup client for the `gangnamunni-clinic-search` k-skill.
## Source
- Search page: `https://www.gangnamunni.com/search?q=<keyword>`
- Data path: the server-rendered Next.js `__NEXT_DATA__` payload, specifically `props.pageProps.hospitals` and related count fields.
This is an unauthenticated public web surface. No proxy or API key is required. The client does not automate login, appointments, chat, payment, reviews, or app-only flows.
## Usage
```js
const { searchClinics } = require("gangnamunni-clinic-search")
const result = await searchClinics({
query: "강남 성형외과",
limit: 5
})
console.log(result.items)
```
CLI:
```bash
npx gangnamunni-clinic-search "강남 성형외과" --limit 5
```
Returned clinic fields include `id`, `name`, `rating`, `ratingCount`, `reviewCount`, `pageCount`, supported `languages`, public image URLs, and the public Gangnam Unni hospital page URL.
## Failure modes
The parser classifies missing embedded Next.js data, login-required responses, CAPTCHA challenges, and blocked responses separately. Result counts and clinic information are point-in-time public page data and may differ from the mobile app or logged-in experience.

View file

@ -0,0 +1,35 @@
{
"name": "gangnamunni-clinic-search",
"version": "0.1.0",
"description": "Public Gangnam Unni clinic search client for k-skill",
"license": "MIT",
"main": "src/index.js",
"bin": {
"gangnamunni-clinic-search": "src/cli.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",
"gangnamunni",
"clinic",
"plastic-surgery",
"k-beauty"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/cli.js && node --check test/index.test.js",
"test": "node --test"
}
}

View file

@ -0,0 +1,45 @@
#!/usr/bin/env node
const { searchClinics } = require("./index")
async function main(options = parseArgs(process.argv.slice(2)), io = console) {
const result = await searchClinics(options)
io.log(JSON.stringify(result, null, 2))
}
function parseArgs(argv) {
const options = {}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (arg === "--query" || arg === "-q") options.query = argv[++i] || ""
else if (arg === "--limit") options.limit = Number(argv[++i])
else if (arg === "--debug") options.debug = true
else if (arg === "--help" || arg === "-h") {
printHelp()
process.exit(0)
} else if (!options.query) {
options.query = arg
}
}
return options
}
function printHelp() {
console.log(`Usage: gangnamunni-clinic-search [query] [options]\n\nOptions:\n -q, --query <text> Search keyword, e.g. "강남 성형외과"\n --limit <number> Maximum clinic results (default: 5)\n --debug Print stack traces for troubleshooting\n`)
}
function formatError(error, options = {}) {
if (options.debug && error && error.stack) return error.stack
return error && error.message ? error.message : String(error)
}
function run(argv = process.argv.slice(2), io = console) {
const options = parseArgs(argv)
return main(options, io).catch((error) => {
io.error(formatError(error, options))
process.exitCode = 1
})
}
if (require.main === module) run()
module.exports = { parseArgs, printHelp, formatError, run, main }

View file

@ -0,0 +1,188 @@
const GANGNAMUNNI_ORIGIN = "https://www.gangnamunni.com"
const GANGNAMUNNI_SEARCH_URL = `${GANGNAMUNNI_ORIGIN}/search`
const SOURCE_ID = "gangnamunni-search-next-data"
function buildSearchUrl(query) {
const params = new URLSearchParams({ q: String(query || "") })
return `${GANGNAMUNNI_SEARCH_URL}?${params.toString()}`
}
async function searchClinics(options = {}) {
const { query, limit = 5, fetcher = global.fetch, signal, timeoutMs = 10000 } = options
const normalizedQuery = cleanText(query)
if (!normalizedQuery) throw new Error("query is required for Gangnam Unni clinic search")
if (!fetcher) throw new Error("fetch is required")
const url = buildSearchUrl(normalizedQuery)
const requestOptions = {
headers: {
"user-agent": "Mozilla/5.0 (compatible; k-skill/gangnamunni-clinic-search)",
accept: "text/html,application/xhtml+xml"
}
}
const requestSignal = signal || createTimeoutSignal(timeoutMs)
if (requestSignal) requestOptions.signal = requestSignal
const response = await fetcher(url, requestOptions)
if (!response || !response.ok) {
const status = response ? `${response.status} ${response.statusText || ""}`.trim() : "no response"
throw new Error(`request failed for ${redactSearchUrl(url)}: ${status}`)
}
const html = await response.text()
return parseSearchHtml(html, { query: normalizedQuery, limit, sourceUrl: url })
}
function parseSearchHtml(html, options = {}) {
const { query = "", limit = 5, sourceUrl = buildSearchUrl(query) } = options
const normalizedLimit = Math.max(1, Number(limit) || 5)
const data = parseNextData(html)
const pageProps = (((data || {}).props || {}).pageProps) || {}
const hospitals = Array.isArray(pageProps.hospitals) ? pageProps.hospitals : []
const parsed = hospitals.map(normalizeHospital).filter((item) => item.id && item.name)
const items = parsed.slice(0, normalizedLimit)
const warnings = []
if (hospitals.length === 0 && Number(pageProps.hospitalTotalLength || 0) > 0) {
warnings.push(`Gangnam Unni reported ${pageProps.hospitalTotalLength} hospitals but embedded no hospital list items`)
}
if (parsed.length > items.length) warnings.push(`returned ${items.length} of ${parsed.length} parsed hospitals; increase limit for more`)
if (Number(pageProps.hospitalTotalLength || 0) > parsed.length) {
warnings.push(`public search page embedded ${parsed.length} of ${pageProps.hospitalTotalLength} matching hospitals`)
}
return {
query: cleanText(pageProps.keyword) || cleanText(query),
totalLength: numericOrNull(pageProps.totalLength),
hospitalTotalLength: numericOrNull(pageProps.hospitalTotalLength),
sourceUrl,
sources: [SOURCE_ID],
warnings,
items
}
}
function parseNextData(html) {
const source = String(html || "")
classifyBlockedBody(source)
const match = source.match(/<script\b[^>]*id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i)
if (!match) throw new Error("Gangnam Unni next data payload not found")
const payload = match[1].trim()
try {
return JSON.parse(payload)
} catch (rawError) {
try {
return JSON.parse(decodeHtmlEntities(payload))
} catch (decodedError) {
const message = `Gangnam Unni next data payload could not be parsed: ${rawError.message}`
throw new Error(`${message}; decoded fallback failed: ${decodedError.message}`)
}
}
}
function createTimeoutSignal(timeoutMs) {
const numericTimeoutMs = Number(timeoutMs)
if (!Number.isFinite(numericTimeoutMs) || numericTimeoutMs <= 0) return null
if (typeof AbortSignal === "undefined" || typeof AbortSignal.timeout !== "function") return null
return AbortSignal.timeout(numericTimeoutMs)
}
function redactSearchUrl(value) {
try {
const url = new URL(String(value))
const serialized = url.toString()
return serialized.replace(/([?&]q=)[^&]*/i, "$1<redacted>")
} catch {
return String(value || "").replace(/([?&]q=)[^&]*/i, "$1<redacted>")
}
}
function classifyBlockedBody(source) {
const text = cleanText(htmlToText(source)).toLowerCase()
if (!text) return
if (/captcha|recaptcha|로봇이 아닙니다|자동화된 요청/.test(text)) throw new Error("Gangnam Unni captcha challenge encountered")
if (/access denied|forbidden|request blocked|too many requests|temporarily blocked|접근이 제한/.test(text)) {
throw new Error("Gangnam Unni request blocked")
}
if (/로그인(이|을)? 필요|sign in required|login required/.test(text)) throw new Error("Gangnam Unni login required")
}
function normalizeHospital(hospital) {
const id = Number(hospital && hospital.id)
return compactObject({
id: Number.isFinite(id) ? id : null,
name: cleanText(hospital && hospital.name),
rating: numericOrNull(hospital && hospital.rating),
ratingCount: numericOrNull(hospital && hospital.ratingCount),
reviewCount: numericOrNull(hospital && hospital.reviewCount),
pageCount: numericOrNull(hospital && hospital.pageCount),
languages: Array.isArray(hospital && hospital.supportingLangList) ? hospital.supportingLangList.filter(Boolean) : [],
assessmentState: cleanText(hospital && hospital.assessmentState),
sido: cleanText(hospital && hospital.sido),
profileImage: safeHttpsUrl(hospital && hospital.profileImage),
mainImage: safeHttpsUrl(hospital && hospital.mainImage),
url: Number.isFinite(id) ? `${GANGNAMUNNI_ORIGIN}/hospitals/${id}` : null
})
}
function compactObject(value) {
return Object.fromEntries(Object.entries(value).filter(([, entry]) => {
if (entry === null || entry === undefined || entry === "") return false
if (Array.isArray(entry) && entry.length === 0) return false
return true
}))
}
function numericOrNull(value) {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : null
}
function safeHttpsUrl(value) {
const text = cleanText(value)
if (!text) return null
try {
const url = new URL(text)
return url.protocol === "https:" ? url.toString() : null
} catch {
return null
}
}
function htmlToText(html) {
return String(html || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
}
function decodeHtmlEntities(value) {
return String(value || "")
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&#x22;/gi, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#39;/g, "'")
.replace(/&#x27;/gi, "'")
}
function cleanText(value) {
return String(value == null ? "" : value).replace(/\s+/g, " ").trim()
}
module.exports = {
GANGNAMUNNI_ORIGIN,
GANGNAMUNNI_SEARCH_URL,
SOURCE_ID,
buildSearchUrl,
searchClinics,
parseSearchHtml,
parseNextData,
normalizeHospital,
createTimeoutSignal,
redactSearchUrl,
cleanText
}

View file

@ -0,0 +1,195 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { spawnSync } = require("node:child_process")
const {
buildSearchUrl,
parseNextData,
normalizeHospital,
parseSearchHtml,
searchClinics
} = require("../src/index")
const sampleNextData = {
props: {
pageProps: {
keyword: "강남 성형외과",
totalLength: 14216,
hospitalTotalLength: 4,
hospitals: [
{
id: 347,
name: "강남삼성성형외과의원",
rating: 9,
ratingCount: 675,
reviewCount: 764,
pageCount: 0,
profileImage: "https://image2.gnsister.com/images/hospital/profile/sample.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/main.jpg",
supportingLangList: ["ko", "ja", "en"],
assessmentState: "EFFORT",
sido: "서울"
},
{
id: 543,
name: "강남서연성형외과의원",
rating: 9.4,
ratingCount: 39,
reviewCount: 83,
pageCount: 8,
profileImage: "https://image2.gnsister.com/images/hospital/profile/other.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/other-main.jpg",
supportingLangList: ["ko", "zh-Hans", "ja"],
assessmentState: "EFFORT",
sido: ""
}
]
}
}
}
const sampleHtml = `<!doctype html><html><body>
<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(sampleNextData).replace(/</g, "\\u003c")}</script>
</body></html>`
test("buildSearchUrl uses the public Gangnam Unni search page", () => {
const url = buildSearchUrl("강남 성형외과")
assert.equal(url, "https://www.gangnamunni.com/search?q=%EA%B0%95%EB%82%A8+%EC%84%B1%ED%98%95%EC%99%B8%EA%B3%BC")
})
test("parseNextData reads escaped Next.js JSON payloads", () => {
const data = parseNextData(sampleHtml)
assert.equal(data.props.pageProps.keyword, "강남 성형외과")
assert.equal(data.props.pageProps.hospitals.length, 2)
})
test("parseNextData preserves literal entity-looking text inside valid JSON strings", () => {
const data = {
props: {
pageProps: {
hospitals: [{ id: 1, name: "A &quot; Clinic &amp; Care" }]
}
}
}
const html = `<script id="__NEXT_DATA__" type="application/json">${JSON.stringify(data)}</script>`
const parsed = parseNextData(html)
assert.equal(parsed.props.pageProps.hospitals[0].name, "A &quot; Clinic &amp; Care")
})
test("parseNextData falls back to entity-decoded legacy payloads", () => {
const html = `<script id="__NEXT_DATA__" type="application/json">{&quot;props&quot;:{&quot;pageProps&quot;:{&quot;keyword&quot;:&quot;강남&quot;}}}</script>`
const parsed = parseNextData(html)
assert.equal(parsed.props.pageProps.keyword, "강남")
})
test("parseNextData classifies login, captcha, blocked, and empty-shell failures", () => {
assert.throws(() => parseNextData("로그인이 필요합니다"), /login required/i)
assert.throws(() => parseNextData("captcha challenge"), /captcha/i)
assert.throws(() => parseNextData("Access Denied"), /blocked/i)
assert.throws(() => parseNextData("<html></html>"), /next data/i)
})
test("normalizeHospital publishes stable public clinic fields only", () => {
assert.deepEqual(normalizeHospital(sampleNextData.props.pageProps.hospitals[0]), {
id: 347,
name: "강남삼성성형외과의원",
rating: 9,
ratingCount: 675,
reviewCount: 764,
pageCount: 0,
languages: ["ko", "ja", "en"],
assessmentState: "EFFORT",
sido: "서울",
profileImage: "https://image2.gnsister.com/images/hospital/profile/sample.jpg",
mainImage: "https://image2.gnsister.com/images/hospital/main.jpg",
url: "https://www.gangnamunni.com/hospitals/347"
})
})
test("parseSearchHtml returns query metadata, limited clinic items, source, and warnings", () => {
const result = parseSearchHtml(sampleHtml, { query: "강남 성형외과", limit: 1 })
assert.equal(result.query, "강남 성형외과")
assert.equal(result.totalLength, 14216)
assert.equal(result.hospitalTotalLength, 4)
assert.equal(result.items.length, 1)
assert.equal(result.items[0].name, "강남삼성성형외과의원")
assert.deepEqual(result.sources, ["gangnamunni-search-next-data"])
assert.match(result.warnings.join("\n"), /returned 1 of 2 parsed hospitals/)
})
test("searchClinics fetches the search page with a default timeout and parses clinics", async () => {
const seen = []
const fetcher = async (url, options) => {
seen.push({ url: String(url), headers: options.headers, signal: options.signal })
return {
ok: true,
status: 200,
statusText: "OK",
text: async () => sampleHtml
}
}
const result = await searchClinics({ query: "강남 성형외과", limit: 2, fetcher })
assert.equal(seen[0].url, buildSearchUrl("강남 성형외과"))
assert.match(seen[0].headers["user-agent"], /k-skill\/gangnamunni-clinic-search/)
assert.ok(seen[0].signal, "expected a default abort signal")
assert.equal(result.items.length, 2)
})
test("searchClinics lets callers inject an abort signal", async () => {
const controller = new AbortController()
let seenSignal
const fetcher = async (_url, options) => {
seenSignal = options.signal
return { ok: true, status: 200, statusText: "OK", text: async () => sampleHtml }
}
await searchClinics({ query: "강남", fetcher, signal: controller.signal })
assert.equal(seenSignal, controller.signal)
})
test("searchClinics rejects missing query and failed upstream responses", async () => {
await assert.rejects(() => searchClinics({ query: "" }), /query is required/)
await assert.rejects(
() => searchClinics({
query: "강남",
fetcher: async () => ({ ok: false, status: 503, statusText: "Service Unavailable" })
}),
(error) => {
assert.match(error.message, /request failed.*503 Service Unavailable/)
assert.match(error.message, /q=<redacted>/)
assert.doesNotMatch(error.message, /%EA%B0%95%EB%82%A8|강남/)
return true
}
)
})
test("CLI parses options and supports help", () => {
const cli = require("../src/cli")
assert.deepEqual(cli.parseArgs(["강남 성형외과", "--limit", "3", "--debug"]), {
query: "강남 성형외과",
limit: 3,
debug: true
})
assert.equal(cli.formatError(new Error("plain failure"), { debug: false }), "plain failure")
assert.match(cli.formatError(new Error("debug failure"), { debug: true }), /Error: debug failure/)
const help = spawnSync(process.execPath, ["src/cli.js", "--help"], {
cwd: __dirname + "/..",
encoding: "utf8"
})
assert.equal(help.status, 0)
assert.match(help.stdout, /Usage: gangnamunni-clinic-search/)
})

View file

@ -8,11 +8,14 @@
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/seoul-density/citydata` — 서울 실시간 도시데이터(`citydata_ppltn`) 핫스팟 혼잡도/추정 인구(`SEOUL_OPEN_API_KEY`)
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(`DATA_GO_KR_API_KEY`; `pageNo=1`, `numOfRows=100` 필수)
- `GET /v1/parking-lots/search` — 전국주차장정보표준데이터 기반 근처 공영주차장 검색(`DATA_GO_KR_API_KEY`)
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `POST /v1/nts-business/status` — 국세청 사업자등록 상태조회(`DATA_GO_KR_API_KEY`)
- `POST /v1/nts-business/validate` — 국세청 사업자등록정보 진위확인(`DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/drug-safety/lookup` — 식약처 의약품개요정보(e약은요) + 안전상비의약품 정보(`DATA_GO_KR_API_KEY`)
- `GET /v1/mfds/food-safety/search` — 식약처 부적합 식품 + 식품안전나라 회수 정보(`DATA_GO_KR_API_KEY`, 선택적 `FOODSAFETYKOREA_API_KEY`)
- `GET /v1/korean-stock/search`
@ -60,7 +63,7 @@
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
- `KSKILL_PROXY_RATE_LIMIT_WINDOW_MS` — 기본 `60000`
- `KSKILL_PROXY_RATE_LIMIT_MAX` — 기본 `60`
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
- `DATA_GO_KR_API_KEY` - 공공데이터포털 에서 쓰이는 API 인증키 (`household-waste`, `parking-lots`, `real-estate`, `nts-business`, `mfds-drug-safety`, `mfds-food-safety`, `lh-notice`). 각 서비스는 공공데이터포털에서 별도 "활용신청" 승인이 필요하다. 키를 발급받은 뒤에는 [LH 임대공고문 정보](https://www.data.go.kr/data/15058530/openapi.do) 페이지에서도 활용신청을 눌러 동일 키를 활성화해야 `lh-notice` 라우트가 성공한다. 미활성 상태에서는 upstream이 HTTP 403 Forbidden을 돌려주고 proxy는 `upstream_error`로 변환한다.
기본 정책은 **무료 API 공개 프록시 = 무인증** 이다. 대신 endpoint scope 를 좁게 유지하고, cache + rate limit 으로 남용을 늦춘다.
@ -72,6 +75,15 @@ node packages/k-skill-proxy/src/server.js
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
국세청 사업자등록 상태조회 예시:
```bash
curl -fsS -X POST 'http://127.0.0.1:4020/v1/nts-business/status' \
-H 'content-type: application/json' \
-d '{"b_no":["123-45-67890"]}'
```
서울 지하철 도착정보 예시:
```bash
@ -79,6 +91,13 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-subway/arrival' \
--data-urlencode 'stationName=강남'
```
서울 실시간 혼잡도 예시 (`SEOUL_OPEN_API_KEY` 필요):
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/seoul-density/citydata' \
--data-urlencode 'area=강남역'
```
한국 날씨 예시:
```bash

View file

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

View file

@ -0,0 +1,206 @@
const NTS_BUSINESSMAN_UPSTREAM_BASE_URL = "https://api.odcloud.kr/api/nts-businessman/v1";
const NTS_BATCH_LIMIT = 100;
const NTS_BUSINESS_OPERATIONS = new Set(["status", "validate"]);
const NTS_VALIDATE_OPTIONAL_TEXT_FIELDS = ["p_nm2", "b_nm", "b_sector", "b_type", "b_adr"];
const NTS_VALIDATE_TEXT_FIELD_LIMITS = {
p_nm: 30,
p_nm2: 30,
b_nm: 200,
b_sector: 100,
b_type: 100,
b_adr: 500
};
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function normalizeBusinessNumber(value) {
const raw = trimOrNull(value);
if (!raw) {
throw new Error("Provide business registration number (b_no). business registration number must be 10 digits.");
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{10}$/.test(normalized)) {
throw new Error("Provide valid business registration number (b_no) as 10 digits.");
}
return normalized;
}
function normalizeNtsBusinessNumbers(value) {
const rawValues = Array.isArray(value) ? value : String(value ?? "").split(",");
const numbers = rawValues
.flatMap((entry) => (Array.isArray(entry) ? entry : [entry]))
.map((entry) => trimOrNull(entry))
.filter(Boolean)
.map(normalizeBusinessNumber);
const unique = [...new Set(numbers)];
if (unique.length === 0) {
throw new Error("Provide b_no as one or more business registration numbers.");
}
if (unique.length > NTS_BATCH_LIMIT) {
throw new Error(`Provide up to ${NTS_BATCH_LIMIT} business registration numbers per request.`);
}
return unique;
}
function normalizeNtsStartDate(value) {
const raw = trimOrNull(value);
if (!raw) {
throw new Error("Provide start_dt as YYYYMMDD.");
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!/^\d{8}$/.test(normalized)) {
throw new Error("Provide start_dt as YYYYMMDD.");
}
const year = Number.parseInt(normalized.slice(0, 4), 10);
const month = Number.parseInt(normalized.slice(4, 6), 10);
const day = Number.parseInt(normalized.slice(6, 8), 10);
const date = new Date(Date.UTC(year, month - 1, day));
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
throw new Error("Provide start_dt as a valid YYYYMMDD date.");
}
return normalized;
}
function normalizeOptionalDigits(value, label) {
const raw = trimOrNull(value);
if (!raw) {
return null;
}
const normalized = raw.replace(/[^0-9]/g, "");
if (!normalized) {
throw new Error(`Provide valid ${label}.`);
}
return normalized;
}
function normalizeNtsValidateText(value, fieldName, { required = false } = {}) {
const normalized = trimOrNull(value);
if (!normalized) {
if (required) {
throw new Error(`Provide ${fieldName} for each business.`);
}
return null;
}
const maxLength = NTS_VALIDATE_TEXT_FIELD_LIMITS[fieldName];
if (maxLength && normalized.length > maxLength) {
throw new Error(`Provide ${fieldName} up to ${maxLength} characters.`);
}
return normalized;
}
function normalizeNtsBusinessStatusQuery(body = {}) {
return {
b_no: normalizeNtsBusinessNumbers(body.b_no ?? body.business_numbers ?? body.businessNumbers)
};
}
function normalizeNtsBusinessValidateItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw new Error("Each business must be an object.");
}
const pNm = normalizeNtsValidateText(
item.p_nm ?? item.owner_name ?? item.ownerName ?? item.representative_name,
"p_nm",
{ required: true }
);
const normalized = {
b_no: normalizeBusinessNumber(item.b_no ?? item.business_number ?? item.businessNumber),
start_dt: normalizeNtsStartDate(item.start_dt ?? item.startDate ?? item.opening_date),
p_nm: pNm
};
for (const key of NTS_VALIDATE_OPTIONAL_TEXT_FIELDS) {
const value = normalizeNtsValidateText(item[key], key);
if (value) {
normalized[key] = value;
}
}
const corpNo = normalizeOptionalDigits(item.corp_no ?? item.corpNo, "corp_no");
if (corpNo) {
if (!/^\d{13}$/.test(corpNo)) {
throw new Error("Provide valid corp_no as 13 digits.");
}
normalized.corp_no = corpNo;
}
return normalized;
}
function normalizeNtsBusinessValidateQuery(body = {}) {
const businesses = body.businesses;
if (!Array.isArray(businesses) || businesses.length === 0) {
throw new Error("Provide businesses as a non-empty array.");
}
if (businesses.length > NTS_BATCH_LIMIT) {
throw new Error(`Provide up to ${NTS_BATCH_LIMIT} businesses per request.`);
}
return {
businesses: businesses.map(normalizeNtsBusinessValidateItem)
};
}
async function proxyNtsBusinessRequest({ operation, payload, serviceKey, fetchImpl = global.fetch }) {
if (!serviceKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server."
})
};
}
if (!NTS_BUSINESS_OPERATIONS.has(operation)) {
return {
statusCode: 404,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "not_found",
message: "That NTS business route is not exposed by this proxy."
})
};
}
const url = new URL(`${NTS_BUSINESSMAN_UPSTREAM_BASE_URL}/${operation}`);
url.searchParams.set("serviceKey", serviceKey);
const response = await fetchImpl(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json"
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(20000)
});
return {
statusCode: response.status,
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
body: await response.text()
};
}
module.exports = {
normalizeBusinessNumber,
normalizeNtsBusinessNumbers,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateItem,
normalizeNtsBusinessValidateQuery,
normalizeNtsStartDate,
proxyNtsBusinessRequest
};

View file

@ -22,6 +22,11 @@ const {
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
const {
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyNtsBusinessRequest
} = require("./nts-business");
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
@ -31,6 +36,7 @@ const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
const KOSIS_OPEN_API_BASE_URL = "https://kosis.kr/openapi";
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
const SEOUL_CITYDATA_BASE_URL = "http://openapi.seoul.go.kr:8088";
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;
@ -480,6 +486,14 @@ function normalizeSeoulSubwayQuery(query) {
};
}
function normalizeSeoulCityDataQuery(query) {
const area = trimOrNull(query.area ?? query.areaNm ?? query.area_nm);
if (!area) {
throw new Error("Provide area.");
}
return { area };
}
function normalizeKosisSearchQuery(query) {
const searchNm = trimOrNull(query.searchNm ?? query.search_nm ?? query.query ?? query.q);
if (!searchNm) {
@ -1049,6 +1063,38 @@ async function proxySeoulSubwayRequest({
};
}
async function proxySeoulCityDataRequest({
area,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "SEOUL_OPEN_API_KEY is not configured on the proxy server."
})
};
}
const encodedArea = encodeURIComponent(area);
const url = new URL(
`${SEOUL_CITYDATA_BASE_URL}/${apiKey}/json/citydata_ppltn/1/1/${encodedArea}`
);
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 proxyKmaWeatherRequest({
baseDate,
baseTime,
@ -1560,7 +1606,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
kosisConfigured: Boolean(config.kosisApiKey),
naverShoppingConfigured: true,
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent
naverNewsApiConfigured: naverSearchKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey)
},
auth: {
tokenRequired: false
@ -1706,6 +1753,66 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
app.get("/v1/seoul-density/citydata", async (request, reply) => {
let normalized;
try {
normalized = normalizeSeoulCityDataQuery(request.query || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: "seoul-density-citydata",
...normalized
});
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
const upstream = await proxySeoulCityDataRequest({
...normalized,
apiKey: config.seoulOpenApiKey
});
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.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;
});
async function handleKosisRoute({ operation, normalize, cacheRoute, request, reply }) {
let normalized;
@ -2635,6 +2742,203 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
return payload;
});
function getNtsUpstreamStatusCode(parsed) {
if (!parsed || typeof parsed !== "object") {
return null;
}
return parsed.status_code
?? parsed.statusCode
?? parsed.resultCode
?? parsed.response?.header?.resultCode
?? null;
}
function isNtsUpstreamSemanticFailure(parsed) {
const statusCode = getNtsUpstreamStatusCode(parsed);
if (statusCode === null || statusCode === undefined) {
return false;
}
return !["OK", "00", "0", "SUCCESS"].includes(String(statusCode).toUpperCase());
}
const ntsValidateSensitiveResponseKeys = new Set([
"b_adr",
"b_nm",
"b_sector",
"b_type",
"corp_no",
"p_nm",
"p_nm2",
"start_dt"
]);
function redactNtsBusinessValidateResponse(value) {
if (Array.isArray(value)) {
return value.map(redactNtsBusinessValidateResponse);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value)
.filter(([key]) => !ntsValidateSensitiveResponseKeys.has(key))
.map(([key, entryValue]) => [key, redactNtsBusinessValidateResponse(entryValue)])
);
}
async function handleNtsBusinessRoute({
operation,
route,
normalizer,
request,
reply,
cacheSuccess = true,
includeQuery = true,
responseMapper = (body) => body
}) {
let normalized;
try {
normalized = normalizer(request.body || {});
} catch (error) {
reply.code(400);
return {
error: "bad_request",
message: error.message
};
}
const cacheKey = cacheSuccess
? makeCacheKey({
route,
...normalized
})
: null;
if (cacheKey) {
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: {
...cached.proxy,
cache: {
hit: true,
ttl_ms: config.cacheTtlMs
}
}
};
}
}
let upstream;
try {
upstream = await proxyNtsBusinessRequest({
operation,
payload: normalized,
serviceKey: config.molitApiKey
});
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: "NTS business upstream request failed.",
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
let parsed;
try {
parsed = JSON.parse(upstream.body);
} catch {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
error: "upstream_invalid_response",
message: "NTS business upstream did not return valid JSON.",
upstream_status: upstream.statusCode,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
}
}
};
}
const responseBody = responseMapper(parsed);
if (
upstream.statusCode < 200
|| upstream.statusCode >= 300
|| parsed.error
|| isNtsUpstreamSemanticFailure(parsed)
) {
reply.code(upstream.statusCode >= 400 ? upstream.statusCode : 502);
return {
...responseBody,
error: parsed.error || "upstream_error",
upstream_status_code: getNtsUpstreamStatusCode(parsed) || undefined,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
}
const payload = {
...responseBody,
proxy: {
name: config.proxyName,
cache: {
hit: false,
ttl_ms: config.cacheTtlMs
},
requested_at: new Date().toISOString()
}
};
if (includeQuery) {
payload.query = normalized;
}
if (cacheKey) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
}
app.post("/v1/nts-business/status", async (request, reply) => handleNtsBusinessRoute({
operation: "status",
route: "nts-business-status",
normalizer: normalizeNtsBusinessStatusQuery,
request,
reply
}));
app.post("/v1/nts-business/validate", async (request, reply) => handleNtsBusinessRoute({
operation: "validate",
route: "nts-business-validate",
normalizer: normalizeNtsBusinessValidateQuery,
cacheSuccess: false,
includeQuery: false,
responseMapper: redactNtsBusinessValidateResponse,
request,
reply
}));
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;
@ -3850,9 +4154,12 @@ module.exports = {
normalizeNeisSchoolMealQuery,
normalizeNeisSchoolSearchQuery,
normalizeNaverShoppingSearchQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
normalizeParkingLotSearchQuery,
normalizeRealEstateQuery,
normalizeRegionCodeQuery,
normalizeSeoulCityDataQuery,
normalizeSeoulSubwayQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
@ -3864,6 +4171,7 @@ module.exports = {
proxyKosisRequest,
fetchNaverShoppingSearch,
proxyOpinetRequest,
proxySeoulCityDataRequest,
proxySeoulSubwayRequest,
resolveLatestKmaForecastBase,
startServer

View file

@ -15,12 +15,15 @@ const {
normalizeKosisDataQuery,
normalizeKosisMetaQuery,
normalizeKosisSearchQuery,
normalizeNtsBusinessStatusQuery,
normalizeNtsBusinessValidateQuery,
proxyAirKoreaRequest,
proxyData4LibraryRequest,
proxyHrfcoWaterLevelRequest,
proxyKakaoLocalRequest,
proxyKosisRequest,
proxyKmaWeatherRequest,
proxySeoulCityDataRequest,
proxySeoulSubwayRequest
} = require("../src/server");
const { resolveEducationOfficeFromNaturalLanguage } = require("../src/neis-office-codes");
@ -151,6 +154,327 @@ test("food-safety search does not cache upstream failures so transient errors se
assert.equal(recallCalls.length, 2, "upstream hit on first (fail) and second (recovered) - third served from cache");
});
test("NTS business normalizers validate status and authenticity payloads", () => {
const tooManyBusinessNumbers = Array.from({ length: 101 }, (_, index) => String(index).padStart(10, "0"));
assert.deepEqual(normalizeNtsBusinessStatusQuery({ b_no: "123-45-67890, 9876543210" }), {
b_no: ["1234567890", "9876543210"]
});
assert.deepEqual(
normalizeNtsBusinessValidateQuery({
businesses: [
{
b_no: "123-45-67890",
start_dt: "2020-01-31",
p_nm: "홍길동",
b_nm: "테스트상사",
corp_no: "110111-1234567"
}
]
}),
{
businesses: [
{
b_no: "1234567890",
start_dt: "20200131",
p_nm: "홍길동",
b_nm: "테스트상사",
corp_no: "1101111234567"
}
]
}
);
assert.throws(() => normalizeNtsBusinessStatusQuery({ b_no: "123" }), /business registration number/);
assert.throws(
() => normalizeNtsBusinessValidateQuery({ businesses: [{ b_no: "1234567890", p_nm: "홍길동" }] }),
/start_dt/
);
assert.throws(
() => normalizeNtsBusinessStatusQuery({ b_no: tooManyBusinessNumbers }),
/up to 100/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", corp_no: "123" }]
}),
/corp_no/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍".repeat(31) }]
}),
/p_nm/
);
assert.throws(
() => normalizeNtsBusinessValidateQuery({
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", b_adr: "가".repeat(501) }]
}),
/b_adr/
);
});
test("NTS business status route proxies POST body with service key server-side", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({ status_code: "OK", request_cnt: 1, data: [{ b_no: "1234567890", b_stt: "계속사업자" }] }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["123-45-67890"] }
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].b_stt, "계속사업자");
assert.equal(body.proxy.cache.hit, false);
assert.match(calls[0].url, /\/nts-businessman\/v1\/status\?serviceKey=data-go-key$/);
assert.deepEqual(JSON.parse(calls[0].options.body), { b_no: ["1234567890"] });
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.headers["content-type"], "application/json");
const cached = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const cachedBody = cached.json();
assert.equal(cached.statusCode, 200);
assert.equal(cachedBody.proxy.cache.hit, true);
assert.equal(calls.length, 1);
});
test("NTS business validate route normalizes businesses and reports missing key", async (t) => {
const missingKeyApp = buildServer();
t.after(async () => {
await missingKeyApp.close();
});
const unavailable = await missingKeyApp.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload: { businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동" }] }
});
const unavailableBody = unavailable.json();
assert.equal(unavailable.statusCode, 503);
assert.equal(unavailableBody.error, "upstream_not_configured");
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({ status_code: "OK", valid_cnt: 1, data: [{ b_no: "1234567890", valid: "01", valid_msg: "확인할 수 있습니다." }] }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload: { businesses: [{ b_no: "123-45-67890", start_dt: "2020.01.01", p_nm: "홍길동", p_nm2: "", b_adr: "서울" }] }
});
const body = response.json();
assert.equal(response.statusCode, 200);
assert.equal(body.data[0].valid, "01");
assert.match(calls[0].url, /\/nts-businessman\/v1\/validate\?serviceKey=data-go-key$/);
assert.deepEqual(JSON.parse(calls[0].options.body), {
businesses: [{ b_no: "1234567890", start_dt: "20200101", p_nm: "홍길동", b_adr: "서울" }]
});
});
test("NTS business validate route does not cache or echo sensitive query fields", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url: String(url), options });
return new Response(
JSON.stringify({
status_code: "OK",
valid_cnt: 1,
data: [{
b_no: "1234567890",
valid: "01",
request_param: {
b_no: "1234567890",
start_dt: "20200101",
p_nm: "홍길동",
b_adr: "서울시 중구"
}
}]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const payload = {
businesses: [{
b_no: "123-45-67890",
start_dt: "2020.01.01",
p_nm: "홍길동",
b_adr: "서울시 중구"
}]
};
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload
});
const firstBody = first.json();
const firstBodyText = JSON.stringify(firstBody);
assert.equal(first.statusCode, 200);
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.query, undefined, "validate responses must not echo representative/date/address inputs");
assert.equal(firstBodyText.includes("홍길동"), false);
assert.equal(firstBodyText.includes("20200101"), false);
assert.equal(firstBodyText.includes("서울시 중구"), false);
assert.deepEqual(firstBody.data[0].request_param, { b_no: "1234567890" });
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/validate",
payload
});
const secondBody = second.json();
assert.equal(second.statusCode, 200);
assert.equal(secondBody.proxy.cache.hit, false);
assert.equal(calls.length, 2, "validate successes must not be cached because they contain sensitive inputs");
});
test("NTS business semantic upstream failures are non-cacheable errors", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({ status_code: "SERVICE_ERROR", message: "upstream service failure" }),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const firstBody = first.json();
assert.equal(first.statusCode, 502);
assert.equal(firstBody.error, "upstream_error");
assert.equal(firstBody.upstream_status_code, "SERVICE_ERROR");
assert.equal(firstBody.proxy.cache.hit, false);
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "semantic upstream failures must not be cached");
});
test("NTS business route maps upstream fetch failures to 502 without caching", async (t) => {
const originalFetch = global.fetch;
let calls = 0;
global.fetch = async () => {
calls += 1;
throw new Error("network down");
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const firstBody = first.json();
assert.equal(first.statusCode, 502);
assert.equal(firstBody.error, "proxy_error");
assert.equal(firstBody.message, "NTS business upstream request failed.");
const second = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
assert.equal(second.statusCode, 502);
assert.equal(calls, 2, "fetch failures must not be cached");
});
test("NTS business route does not leak service keys from upstream fetch exception messages", async (t) => {
const originalFetch = global.fetch;
global.fetch = async (url) => {
throw new Error(`proxy tunnel failed for ${url}`);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "super-secret-data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "POST",
url: "/v1/nts-business/status",
payload: { b_no: ["1234567890"] }
});
const body = response.json();
const bodyText = JSON.stringify(body);
assert.equal(response.statusCode, 502);
assert.equal(body.error, "proxy_error");
assert.equal(body.message, "NTS business upstream request failed.");
assert.equal(bodyText.includes("super-secret-data-go-key"), false);
assert.equal(bodyText.includes("serviceKey"), false);
});
test("health endpoint stays public and reports auth/upstream status", async (t) => {
const app = buildServer({
provider: async () => {
@ -1323,6 +1647,145 @@ test("proxySeoulSubwayRequest injects API key and preserves index/station params
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
});
test("seoul density endpoint caches successful upstream responses for normalized area queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(
JSON.stringify({
"SeoulRtd.citydata_ppltn": [
{
AREA_NM: "강남역",
AREA_CONGEST_LVL: "약간 붐빔",
AREA_PPLTN_MIN: "24000",
AREA_PPLTN_MAX: "26000",
PPLTN_TIME: "2026-05-14 09:30",
AREA_CONGEST_MSG: "사람이 몰려있을 수 있어요"
}
],
RESULT: { "RESULT.CODE": "INFO-000", "RESULT.MESSAGE": "정상 처리되었습니다." }
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: {
SEOUL_OPEN_API_KEY: "seoul-key",
KSKILL_PROXY_CACHE_TTL_MS: "60000"
}
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const first = await app.inject({
method: "GET",
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
});
const second = await app.inject({
method: "GET",
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
});
assert.equal(first.statusCode, 200);
assert.equal(second.statusCode, 200);
assert.equal(fetchCalls, 1);
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
});
test("seoul density endpoint stays publicly callable without proxy auth", async (t) => {
const originalFetch = global.fetch;
let calledUrl;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(
JSON.stringify({
"SeoulRtd.citydata_ppltn": [{ AREA_NM: "강남역" }],
RESULT: { "RESULT.CODE": "INFO-000" }
}),
{
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
}
);
};
const app = buildServer({
env: { SEOUL_OPEN_API_KEY: "seoul-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
});
assert.equal(response.statusCode, 200);
assert.match(calledUrl, /\/seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/);
});
test("seoul density endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-density/citydata?area=%EA%B0%95%EB%82%A8%EC%97%AD"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("seoul density endpoint returns 400 when area is missing", async (t) => {
const app = buildServer({ env: { SEOUL_OPEN_API_KEY: "seoul-key" } });
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/seoul-density/citydata"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("proxySeoulCityDataRequest injects API key and encodes area name", async () => {
let calledUrl;
const result = await proxySeoulCityDataRequest({
area: "강남역",
apiKey: "test-seoul-key",
fetchImpl: async (url) => {
calledUrl = String(url);
return new Response('{"ok":true}', {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
}
});
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /\/test-seoul-key\/json\/citydata_ppltn\/1\/1\/%EA%B0%95%EB%82%A8%EC%97%AD$/);
});
test("korea weather endpoint caches successful upstream responses for normalized coordinate queries", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;

View file

@ -0,0 +1,211 @@
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import re
import sys
import urllib.error
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
BATCH_LIMIT = 100
VALIDATE_TEXT_FIELD_LIMITS = {
"p_nm": 30,
"p_nm2": 30,
"b_nm": 200,
"b_sector": 100,
"b_type": 100,
"b_adr": 500,
}
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 _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit_base_url: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit_base_url or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_business_number(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(b_no)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def normalize_start_date(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("개업일자(start_dt)를 YYYYMMDD 형식으로 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{8}", normalized):
raise ValueError("개업일자는 YYYYMMDD 형식이어야 합니다.")
try:
dt.date(int(normalized[:4]), int(normalized[4:6]), int(normalized[6:8]))
except ValueError as error:
raise ValueError("개업일자는 유효한 날짜여야 합니다.") from error
return normalized
def normalize_validate_text(value: Any, field_name: str, *, required: bool = False) -> str | None:
text = _text_or_none(value)
if not text:
if required:
raise ValueError(f"{field_name}을(를) 입력하세요.")
return None
max_length = VALIDATE_TEXT_FIELD_LIMITS.get(field_name)
if max_length and len(text) > max_length:
raise ValueError(f"{field_name}은(는) {max_length}자 이하여야 합니다.")
return text
def normalize_corp_no(value: Any) -> str | None:
raw = _text_or_none(value)
if not raw:
return None
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{13}", normalized):
raise ValueError("corp_no는 숫자 13자리여야 합니다.")
return normalized
def build_status_payload(business_numbers: list[Any]) -> dict[str, list[str]]:
numbers = [normalize_business_number(value) for value in business_numbers]
numbers = list(dict.fromkeys(numbers))
if not numbers:
raise ValueError("사업자등록번호를 1개 이상 입력하세요.")
if len(numbers) > BATCH_LIMIT:
raise ValueError("한 번에 조회할 수 있는 사업자등록번호는 100개까지입니다.")
return {"b_no": numbers}
def build_validate_business(**kwargs: Any) -> dict[str, str]:
p_nm = normalize_validate_text(kwargs.get("p_nm"), "p_nm", required=True)
business = {
"b_no": normalize_business_number(kwargs.get("b_no")),
"start_dt": normalize_start_date(kwargs.get("start_dt")),
"p_nm": p_nm,
}
for key in ("p_nm2", "b_nm", "b_sector", "b_type", "b_adr"):
value = normalize_validate_text(kwargs.get(key), key)
if value:
business[key] = value
corp_no = normalize_corp_no(kwargs.get("corp_no"))
if corp_no:
business["corp_no"] = corp_no
return business
def build_validate_payload(businesses: list[dict[str, Any]]) -> dict[str, list[dict[str, str]]]:
if not businesses:
raise ValueError("진위확인 대상 businesses를 1개 이상 입력하세요.")
if len(businesses) > BATCH_LIMIT:
raise ValueError("한 번에 진위확인할 수 있는 사업자는 100개까지입니다.")
return {"businesses": [build_validate_business(**business) for business in businesses]}
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, url=getattr(error, "url", None)) from error
raise ApiError(f"NTS business proxy request failed with HTTP {error.code}", status_code=error.code, url=getattr(error, "url", None)) from error
except urllib.error.URLError as error:
raise ApiError(f"NTS business proxy request failed: {error.reason}") from error
def _post_json(path: str, payload: dict[str, Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
resolved_base_url = resolve_proxy_base_url(base_url)
request = urllib.request.Request(
f"{resolved_base_url}{path}",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "k-skill-nts-business-registration/1.0",
},
method="POST",
)
return read_json(request)
def query_status(business_numbers: list[Any], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/status", build_status_payload(business_numbers), base_url=base_url, read_json=read_json)
def validate_businesses(businesses: list[dict[str, Any]], *, base_url: str | None = None, read_json: Any = read_json_response) -> dict[str, Any]:
return _post_json("/v1/nts-business/validate", build_validate_payload(businesses), base_url=base_url, read_json=read_json)
def _parse_business_json(value: str) -> dict[str, Any]:
payload = json.loads(value)
if not isinstance(payload, dict):
raise argparse.ArgumentTypeError("business JSON must be an object")
return payload
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="NTS business registration status/authenticity helper")
subparsers = parser.add_subparsers(dest="command", required=True)
status = subparsers.add_parser("status", help="사업자등록번호 상태조회")
status.add_argument("--b-no", action="append", required=True, help="사업자등록번호(10자리; 하이픈 허용). 여러 번 지정 가능")
status.add_argument("--proxy-base-url")
validate = subparsers.add_parser("validate", help="사업자등록정보 진위확인")
validate.add_argument("--business-json", action="append", type=_parse_business_json, required=True, help='예: {"b_no":"1234567890","start_dt":"20200101","p_nm":"홍길동"}')
validate.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.command == "status":
print(json.dumps(query_status(args.b_no, base_url=args.proxy_base_url), ensure_ascii=False, indent=2))
return 0
if args.command == "validate":
print(json.dumps(validate_businesses(args.business_json, base_url=args.proxy_base_url), 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())

259
scripts/seoul_density.py Normal file
View file

@ -0,0 +1,259 @@
"""Single-entrypoint CLI for the seoul-density skill.
All skill operations route through `python3 seoul-density/scripts/seoul_density.py <subcommand>`
so users only have to approve one Bash pattern on first use.
Subcommands:
list print supported area names grouped by category
match <keyword> fuzzy-match a user keyword to a supported area name
query <area-name> [--json] fetch and summarize real-time density for the area
"""
from __future__ import annotations
import argparse
import difflib
import json
import os
import sys
import urllib.error
import urllib.request
import urllib.parse
from typing import Any
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (OSError, ValueError):
pass
AREAS: dict[str, list[str]] = {
"고궁·문화유산": [
"경복궁", "광화문·덕수궁", "보신각", "서울 암사동 유적", "창덕궁·종묘",
],
"관광특구": [
"강남 MICE 관광특구", "동대문 관광특구", "명동 관광특구", "이태원 관광특구",
"잠실 관광특구", "종로·청계 관광특구", "홍대 관광특구",
],
"공원": [
"강서한강공원", "고척돔", "광나루한강공원", "광화문광장",
"국립중앙박물관·용산가족공원", "난지한강공원", "남산공원", "노들섬",
"뚝섬한강공원", "망원한강공원", "반포한강공원", "보라매공원",
"북서울꿈의숲", "서대문독립공원", "서리풀공원·몽마르뜨공원", "서울대공원",
"서울숲공원", "송현녹지광장", "아차산", "안양천", "양화한강공원",
"어린이대공원", "여의도한강공원", "여의서로", "올림픽공원", "월드컵공원",
"응봉산", "이촌한강공원", "잠실종합운동장", "잠실한강공원", "잠원한강공원",
"청계산", "홍제폭포",
],
"발달상권": [
"가락시장", "가로수길", "광장(전통)시장", "김포공항", "남대문시장", "노량진",
"덕수궁길·정동길", "북창동 먹자골목", "북촌한옥마을", "서촌", "성수카페거리",
"송리단길·호수단길", "신촌 스타광장", "압구정로데오거리", "여의도", "연남동",
"영등포 타임스퀘어", "용리단길", "이태원 앤틱가구거리", "익선동", "인사동",
"잠실롯데타워·석촌호수", "창동 신경제 중심지", "청담동 명품거리",
"청량리 제기동 일대 전통시장", "해방촌·경리단길", "DDP(동대문디자인플라자)",
"DMC(디지털미디어시티)",
],
"인구밀집지역": [
"가산디지털단지역", "강남역", "건대입구역", "고덕역", "고속터미널역", "교대역",
"구로디지털단지역", "구로역", "군자역", "대림역", "동대문역", "뚝섬역",
"미아사거리역", "발산역", "사당역", "삼각지역", "서울대입구역",
"서울식물원·마곡나루역", "서울역", "성신여대입구역", "선릉역", "시의회 앞",
"수유역", "신논현역·논현역", "신도림역", "신림역", "신촌·이대역", "쌍문역",
"신정네거리역", "역삼역", "연신내역", "양재역", "왕십리역", "용산역",
"오목교역·목동운동장", "잠실새내역", "잠실역", "장지역", "장한평역", "천호역",
"총신대입구(이수)역", "충정로역", "합정역", "혜화역", "홍대입구역(2호선)",
"회기역",
],
}
TIMEOUT_SEC = 10
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
def all_areas() -> list[str]:
return [name for group in AREAS.values() for name in group]
def cmd_list(args: argparse.Namespace) -> int:
if args.json:
json.dump(AREAS, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
for category, names in AREAS.items():
print(f"## {category} ({len(names)}곳)")
print(", ".join(names))
print()
return 0
def _normalize(text: str) -> str:
"""Strip whitespace and common location suffixes for loose matching."""
cleaned = "".join(ch for ch in text if not ch.isspace())
for suffix in ("관광특구", "한강공원", "공원", "시장", "", "거리", "광장"):
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
cleaned = cleaned[: -len(suffix)]
break
return cleaned
def fuzzy_match(keyword: str, limit: int = 5) -> list[str]:
names = all_areas()
keyword = keyword.strip()
if not keyword:
return []
exact = [n for n in names if keyword in n]
if exact:
return exact[:limit]
contained = [n for n in names if n in keyword]
if contained:
return contained[:limit]
norm_kw = _normalize(keyword)
if norm_kw:
loose = [n for n in names if norm_kw and (norm_kw in _normalize(n) or _normalize(n) in norm_kw)]
if loose:
return loose[:limit]
return difflib.get_close_matches(keyword, names, n=limit, cutoff=0.3)
def cmd_match(args: argparse.Namespace) -> int:
matches = fuzzy_match(args.keyword, limit=args.limit)
if not matches:
print(f"'{args.keyword}'와 일치하는 지원 장소가 없습니다.", file=sys.stderr)
print("'python3 seoul-density/scripts/seoul_density.py list' 로 전체 목록을 확인하세요.", file=sys.stderr)
return 1
if args.json:
json.dump(matches, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
else:
for name in matches:
print(name)
return 0
def get_proxy_base_url() -> str:
value = os.environ.get(PROXY_BASE_URL_NAME)
if value and value != "replace-me":
return value.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def fetch_density_via_proxy(area: str) -> dict[str, Any]:
base_url = get_proxy_base_url()
query = urllib.parse.urlencode({"area": area})
url = f"{base_url}/v1/seoul-density/citydata?{query}"
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-density"})
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
def summarize(payload: dict[str, Any]) -> dict[str, Any]:
result = payload.get("RESULT") or {}
code = result.get("RESULT.CODE")
message = result.get("RESULT.MESSAGE", "")
if code and code != "INFO-000":
raise RuntimeError(f"API 오류: {code} {message}".strip())
rows = payload.get("SeoulRtd.citydata_ppltn") or []
if not rows:
raise RuntimeError("인구 데이터가 없습니다. 장소명을 'match' 서브커맨드로 확인하세요.")
row = rows[0]
return {
"area": row.get("AREA_NM"),
"congestion_level": row.get("AREA_CONGEST_LVL"),
"population_min": row.get("AREA_PPLTN_MIN"),
"population_max": row.get("AREA_PPLTN_MAX"),
"as_of": row.get("PPLTN_TIME"),
"message": row.get("AREA_CONGEST_MSG"),
}
def cmd_query(args: argparse.Namespace) -> int:
area = args.area.strip()
if area not in all_areas():
suggestions = fuzzy_match(area, limit=3)
if len(suggestions) == 1 and getattr(args, "auto", True):
print(f"'{area}''{suggestions[0]}' 로 자동 매칭", file=sys.stderr)
area = suggestions[0]
else:
hint = (
f" 가까운 후보: {', '.join(suggestions)}" if suggestions else ""
)
print(f"지원하지 않는 장소: {area}{hint}", file=sys.stderr)
return 1
try:
payload = fetch_density_via_proxy(area)
summary = summarize(payload)
except urllib.error.HTTPError as exc:
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"API 연결 실패: {exc.reason}", file=sys.stderr)
return 1
except (RuntimeError, json.JSONDecodeError) as exc:
print(str(exc), file=sys.stderr)
return 1
if args.json:
json.dump(summary, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
print(f"장소: {summary['area']}")
print(f"혼잡도: {summary['congestion_level']}")
print(f"인구 추정: {summary['population_min']}~{summary['population_max']}")
print(f"기준 시각: {summary['as_of'] or '알 수 없음'}")
print(f"상황: {summary['message']}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="seoul_density",
description="서울 실시간 도시데이터(혼잡도/인구) 단일 진입점 CLI",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_list = sub.add_parser("list", help="지원 장소 목록 출력")
p_list.add_argument("--json", action="store_true")
p_list.set_defaults(func=cmd_list)
p_match = sub.add_parser("match", help="키워드 → 지원 장소명 매칭")
p_match.add_argument("keyword")
p_match.add_argument("--limit", type=int, default=5)
p_match.add_argument("--json", action="store_true")
p_match.set_defaults(func=cmd_match)
p_query = sub.add_parser("query", help="장소 혼잡도 조회")
p_query.add_argument("area", help="지원 장소명 (목록은 'list' 참조)")
p_query.add_argument("--json", action="store_true")
p_query.add_argument(
"--no-auto",
dest="auto",
action="store_false",
help="후보가 1개뿐이어도 자동 매칭하지 않음",
)
p_query.set_defaults(func=cmd_query, auto=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -186,6 +186,40 @@ test("README links to the contribution guide", () => {
assert.match(readme, /\[기여 가이드\]\(CONTRIBUTING\.md\)/);
});
test("repository docs advertise Daangn read-only search skills", () => {
const readme = read("README.md");
const sources = read(path.join("docs", "sources.md"));
const skills = [
["daangn-used-goods-search", "당근 중고거래 검색"],
["daangn-realty-search", "당근부동산 검색"],
["daangn-jobs-search", "당근알바 검색"],
["daangn-cars-search", "당근중고차 검색"],
];
assert.match(sources, /www\.daangn\.com\/kr\/api\/v1\/regions\/keyword/);
assert.match(sources, /realty\.daangn\.com\/articles/);
for (const [skillName, label] of skills) {
const skill = read(path.join(skillName, "SKILL.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", `${skillName}.md`);
const featureDoc = read(path.join("docs", "features", `${skillName}.md`));
assert.ok(fs.existsSync(featureDocPath), `expected docs/features/${skillName}.md to exist`);
assert.ok(
readme.includes(`| ${label} | \`${skillName}\``),
`README should advertise ${skillName}`,
);
assert.ok(
readme.includes(`](docs/features/${skillName}.md)`),
`README should link docs/features/${skillName}.md`,
);
assert.match(skill, /kr\/api\/v1\/regions\/keyword/);
assert.match(featureDoc, /kr\/api\/v1\/regions\/keyword/);
assert.match(featureDoc, /(로그인|채팅|구매|문의|지원).*자동화/);
}
});
test("hwp skill documents kordoc-based parsing and supported operations", () => {
const skillPath = path.join(repoRoot, "hwp", "SKILL.md");
@ -1041,6 +1075,23 @@ 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 gangnamunni-clinic-search skill across install surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "gangnamunni-clinic-search.md");
const skillPath = path.join(repoRoot, "gangnamunni-clinic-search", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/gangnamunni-clinic-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected gangnamunni-clinic-search/SKILL.md to exist");
assert.match(readme, /\| 강남언니 병원 조회 \| `gangnamunni-clinic-search` \|/);
assert.match(readme, /\[강남언니 병원 조회 가이드\]\(docs\/features\/gangnamunni-clinic-search\.md\)/);
assert.match(install, /--skill gangnamunni-clinic-search/);
assert.match(install, /npm install -g .*gangnamunni-clinic-search/);
assert.match(sources, /강남언니 공개 검색: https:\/\/www\.gangnamunni\.com\/search\?q=<keyword>/);
assert.match(sources, /강남언니 공개 병원 페이지: https:\/\/www\.gangnamunni\.com\/hospitals\/<id>/);
});
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"));

View file

@ -0,0 +1,137 @@
import json
import importlib.util
from pathlib import Path
import unittest
import urllib.error
from scripts.nts_business_registration import (
ApiError,
build_status_payload,
build_validate_business,
normalize_business_number,
normalize_start_date,
query_status,
resolve_proxy_base_url,
validate_businesses,
)
class NtsBusinessNormalizationTest(unittest.TestCase):
def test_normalize_business_number_keeps_ten_digits_only(self):
self.assertEqual(normalize_business_number("123-45-67890"), "1234567890")
with self.assertRaisesRegex(ValueError, "사업자등록번호"):
normalize_business_number("123")
def test_normalize_start_date_accepts_common_date_separators(self):
self.assertEqual(normalize_start_date("2020-01-31"), "20200131")
self.assertEqual(normalize_start_date("2020.01.31"), "20200131")
with self.assertRaisesRegex(ValueError, "개업일자"):
normalize_start_date("2020-13-01")
def test_build_status_payload_limits_batch_size(self):
self.assertEqual(build_status_payload(["123-45-67890"]), {"b_no": ["1234567890"]})
with self.assertRaisesRegex(ValueError, "100개"):
build_status_payload([f"{index:010d}" for index in range(101)])
def test_build_validate_business_trims_optional_fields(self):
business = build_validate_business(
b_no="123-45-67890",
start_dt="2020-01-31",
p_nm=" 홍길동 ",
b_nm="테스트상사",
corp_no="110111-1234567",
p_nm2="",
)
self.assertEqual(
business,
{
"b_no": "1234567890",
"start_dt": "20200131",
"p_nm": "홍길동",
"b_nm": "테스트상사",
"corp_no": "1101111234567",
},
)
def test_build_validate_business_rejects_malformed_or_oversized_optional_fields(self):
base = {"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}
with self.assertRaisesRegex(ValueError, "corp_no"):
build_validate_business(**base, corp_no="123")
with self.assertRaisesRegex(ValueError, "p_nm"):
build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="" * 31)
with self.assertRaisesRegex(ValueError, "b_adr"):
build_validate_business(**base, b_adr="" * 501)
def test_skill_local_helper_matches_runtime_validation_behavior(self):
helper_path = Path(__file__).resolve().parents[1] / "nts-business-registration" / "scripts" / "nts_business_registration.py"
spec = importlib.util.spec_from_file_location("skill_local_nts_business_registration", helper_path)
self.assertIsNotNone(spec)
self.assertIsNotNone(spec.loader)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.assertEqual(
module.build_validate_business(
b_no="123-45-67890",
start_dt="2020.01.31",
p_nm="홍길동",
corp_no="110111-1234567",
),
{
"b_no": "1234567890",
"start_dt": "20200131",
"p_nm": "홍길동",
"corp_no": "1101111234567",
},
)
with self.assertRaisesRegex(ValueError, "corp_no"):
module.build_validate_business(b_no="1234567890", start_dt="20200101", p_nm="홍길동", corp_no="abc")
class NtsBusinessProxyTest(unittest.TestCase):
def test_query_status_posts_to_proxy_route(self):
captured = {}
def fake_read_json(request):
captured["url"] = request.full_url
captured["data"] = json.loads(request.data.decode("utf-8"))
captured["method"] = request.get_method()
return {"data": [{"b_no": "1234567890", "b_stt": "계속사업자"}]}
payload = query_status(["123-45-67890"], base_url="https://proxy.example.com", read_json=fake_read_json)
self.assertEqual(payload["data"][0]["b_stt"], "계속사업자")
self.assertEqual(captured["url"], "https://proxy.example.com/v1/nts-business/status")
self.assertEqual(captured["data"], {"b_no": ["1234567890"]})
self.assertEqual(captured["method"], "POST")
def test_validate_businesses_posts_to_proxy_route(self):
captured = {}
def fake_read_json(request):
captured["url"] = request.full_url
captured["data"] = json.loads(request.data.decode("utf-8"))
return {"data": [{"valid": "01"}]}
payload = validate_businesses(
[{"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}],
base_url="https://proxy.example.com/",
read_json=fake_read_json,
)
self.assertEqual(payload["data"][0]["valid"], "01")
self.assertEqual(captured["url"], "https://proxy.example.com/v1/nts-business/validate")
self.assertEqual(captured["data"], {"businesses": [{"b_no": "1234567890", "start_dt": "20200101", "p_nm": "홍길동"}]})
def test_resolve_proxy_base_url_defaults_to_hosted_proxy(self):
self.assertEqual(resolve_proxy_base_url(None, env={}), "https://k-skill-proxy.nomadamas.org")
self.assertEqual(resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "https://proxy.example.com/"}), "https://proxy.example.com")
with self.assertRaisesRegex(ValueError, "KSKILL_PROXY_BASE_URL"):
resolve_proxy_base_url(None, env={"KSKILL_PROXY_BASE_URL": "off"})
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,152 @@
"""Tests for seoul_density CLI helpers (no network access)."""
from __future__ import annotations
import io
import json
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest import mock
import seoul_density as sd
class FuzzyMatchTests(unittest.TestCase):
def test_exact_substring_wins(self) -> None:
result = sd.fuzzy_match("강남역")
self.assertIn("강남역", result)
def test_keyword_contained_in_area(self) -> None:
result = sd.fuzzy_match("홍대")
self.assertTrue(any("홍대" in name for name in result))
def test_close_match_fallback(self) -> None:
result = sd.fuzzy_match("여의도공원")
self.assertTrue(result, "close match should return at least one candidate")
def test_loose_match_strips_역_suffix(self) -> None:
result = sd.fuzzy_match("강남")
self.assertIn("강남역", result)
class SummarizeTests(unittest.TestCase):
def test_ok_payload(self) -> None:
payload = {
"RESULT": {"RESULT.CODE": "INFO-000", "RESULT.MESSAGE": "OK"},
"SeoulRtd.citydata_ppltn": [
{
"AREA_NM": "강남역",
"AREA_CONGEST_LVL": "붐빔",
"AREA_PPLTN_MIN": "30000",
"AREA_PPLTN_MAX": "32000",
"PPLTN_TIME": "2026-05-14 09:30",
"AREA_CONGEST_MSG": "평소보다 매우 많은 인파",
}
],
}
summary = sd.summarize(payload)
self.assertEqual(summary["area"], "강남역")
self.assertEqual(summary["congestion_level"], "붐빔")
def test_api_error_code_raises(self) -> None:
payload = {"RESULT": {"RESULT.CODE": "ERROR-300", "RESULT.MESSAGE": "bad key"}}
with self.assertRaises(RuntimeError):
sd.summarize(payload)
def test_empty_rows_raises(self) -> None:
payload = {"RESULT": {"RESULT.CODE": "INFO-000"}, "SeoulRtd.citydata_ppltn": []}
with self.assertRaises(RuntimeError):
sd.summarize(payload)
class CLITests(unittest.TestCase):
def test_list_json(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf):
rc = sd.main(["list", "--json"])
self.assertEqual(rc, 0)
data = json.loads(buf.getvalue())
self.assertIn("관광특구", data)
def test_match_unknown_keyword(self) -> None:
err = io.StringIO()
with redirect_stderr(err):
rc = sd.main(["match", "절대로_존재하지_않는_장소_xyzzy"])
self.assertEqual(rc, 1)
def test_query_unsupported_area(self) -> None:
err = io.StringIO()
with redirect_stderr(err):
rc = sd.main(["query", "존재하지않는장소xyzzy"])
self.assertEqual(rc, 1)
def test_query_auto_matches_single_candidate(self) -> None:
payload = {
"RESULT": {"RESULT.CODE": "INFO-000"},
"SeoulRtd.citydata_ppltn": [
{
"AREA_NM": "서울 암사동 유적",
"AREA_CONGEST_LVL": "보통",
"AREA_PPLTN_MIN": "1000",
"AREA_PPLTN_MAX": "1200",
"PPLTN_TIME": "2026-05-14 10:00",
"AREA_CONGEST_MSG": "평소와 비슷",
}
],
}
captured: dict[str, str] = {}
def fake_proxy(area: str) -> dict:
captured["area"] = area
return payload
buf = io.StringIO()
err = io.StringIO()
with mock.patch.object(sd, "fetch_density_via_proxy", side_effect=fake_proxy), \
redirect_stdout(buf), redirect_stderr(err):
rc = sd.main(["query", "암사동"])
self.assertEqual(rc, 0)
self.assertEqual(captured.get("area"), "서울 암사동 유적")
self.assertIn("자동 매칭", err.getvalue())
def test_no_auto_disables_single_match(self) -> None:
err = io.StringIO()
with redirect_stderr(err):
rc = sd.main(["query", "암사동", "--no-auto"])
self.assertEqual(rc, 1)
def test_query_happy_path(self) -> None:
payload = {
"RESULT": {"RESULT.CODE": "INFO-000"},
"SeoulRtd.citydata_ppltn": [
{
"AREA_NM": "강남역",
"AREA_CONGEST_LVL": "보통",
"AREA_PPLTN_MIN": "10000",
"AREA_PPLTN_MAX": "12000",
"PPLTN_TIME": "2026-05-14 09:00",
"AREA_CONGEST_MSG": "평소와 비슷",
}
],
}
buf = io.StringIO()
with mock.patch.object(sd, "fetch_density_via_proxy", return_value=payload), \
redirect_stdout(buf):
rc = sd.main(["query", "강남역", "--json"])
self.assertEqual(rc, 0)
out = json.loads(buf.getvalue())
self.assertEqual(out["congestion_level"], "보통")
class ProxyHelpersTests(unittest.TestCase):
def test_proxy_base_url_default(self) -> None:
with mock.patch.dict("os.environ", {}, clear=True):
self.assertEqual(sd.get_proxy_base_url(), sd.DEFAULT_PROXY_BASE_URL)
def test_proxy_base_url_custom_strips_trailing_slash(self) -> None:
with mock.patch.dict("os.environ", {"KSKILL_PROXY_BASE_URL": "https://example.com/"}, clear=True):
self.assertEqual(sd.get_proxy_base_url(), "https://example.com")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,210 @@
import json
import unittest
from unittest import mock
from scripts.ticket_availability import (
HEADERS_INTERPARK,
HEADERS_YES24,
INTERPARK_BASE,
YES24_BASE,
InterparkClient,
Yes24Client,
_fmt_date,
_fmt_time,
parse_url,
)
class ParseUrlTest(unittest.TestCase):
def test_yes24_full_url(self):
self.assertEqual(
parse_url("https://ticket.yes24.com/Perf/58026"),
("yes24", "58026"),
)
def test_yes24_detail_view_url(self):
self.assertEqual(
parse_url("https://ticket.yes24.com/New/Perf/Detail/View/58026"),
("yes24", "58026"),
)
def test_yes24_shorthand(self):
self.assertEqual(parse_url("yes24:58026"), ("yes24", "58026"))
def test_interpark_full_url(self):
self.assertEqual(
parse_url("https://tickets.interpark.com/goods/26000541"),
("interpark", "26000541"),
)
def test_interpark_shorthand(self):
self.assertEqual(
parse_url("interpark:26000541"), ("interpark", "26000541")
)
def test_bare_digits_requires_platform_prefix(self):
with self.assertRaisesRegex(ValueError, "플랫폼"):
parse_url("26000541")
def test_unrecognized_url_raises(self):
with self.assertRaisesRegex(ValueError, "인식할 수 없습니다"):
parse_url("https://example.com/foo")
class FormatHelpersTest(unittest.TestCase):
def test_fmt_date_yyyymmdd(self):
self.assertEqual(_fmt_date("20260513"), "2026-05-13")
def test_fmt_date_passes_through_non_yyyymmdd(self):
self.assertEqual(_fmt_date("2026-05-13"), "2026-05-13")
self.assertEqual(_fmt_date(""), "")
def test_fmt_time_hhmm(self):
self.assertEqual(_fmt_time("1430"), "14:30")
def test_fmt_time_passes_through_non_hhmm(self):
self.assertEqual(_fmt_time("14:30"), "14:30")
self.assertEqual(_fmt_time(""), "")
class Yes24ClientTest(unittest.TestCase):
def test_get_dates_normalizes_dashed_response_and_filters_past(self):
client = Yes24Client.__new__(Yes24Client)
client.http = mock.Mock()
client.http.post.return_value = mock.Mock(
text="2099-12-16,2099-12-17,",
raise_for_status=lambda: None,
)
dates = client._dates("58026", month_count=1)
self.assertEqual(dates, ["20991216", "20991217"])
called_url = client.http.post.call_args.args[0]
self.assertIn("axPerfDay.aspx", called_url)
def test_get_dates_filters_dates_before_today(self):
client = Yes24Client.__new__(Yes24Client)
client.http = mock.Mock()
client.http.post.return_value = mock.Mock(
text="1999-01-01,2099-12-16,",
raise_for_status=lambda: None,
)
dates = client._dates("58026", month_count=1)
self.assertEqual(dates, ["20991216"])
def test_get_seats_parses_remain_count(self):
client = Yes24Client.__new__(Yes24Client)
client.http = mock.Mock()
client.http.post.return_value = mock.Mock(
text='<dt>R석</dt><dd>110,000원<span class="">(잔여:5석)</span></dd>'
'<dt>S석</dt><dd>80,000원<span>(잔여:12석)</span></dd>',
raise_for_status=lambda: None,
)
seats = client.get_seats("1432397")
self.assertEqual(
seats,
[
{"grade": "R석", "price": "110,000원", "remain": 5},
{"grade": "S석", "price": "80,000원", "remain": 12},
],
)
def test_get_seats_fallback_when_no_dt_dd_structure(self):
client = Yes24Client.__new__(Yes24Client)
client.http = mock.Mock()
client.http.post.return_value = mock.Mock(
text="<span>(잔여:2석)</span>",
raise_for_status=lambda: None,
)
seats = client.get_seats("1432397")
self.assertEqual(seats, [{"grade": "좌석1", "price": "", "remain": 2}])
class InterparkClientTest(unittest.TestCase):
def test_get_schedule_returns_data_field(self):
client = InterparkClient.__new__(InterparkClient)
client.http = mock.Mock()
client.http.get.return_value = mock.Mock(
json=lambda: {
"common": {"message": "success"},
"data": [
{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}
],
},
raise_for_status=lambda: None,
)
result = client.get_schedule("26000541")
self.assertEqual(
result,
[{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}],
)
called_url = client.http.get.call_args.args[0]
self.assertIn("/v1/goods/26000541/playSeq", called_url)
def test_get_seats_extracts_remain_seat(self):
client = InterparkClient.__new__(InterparkClient)
client.http = mock.Mock()
client.http.get.return_value = mock.Mock(
json=lambda: {
"data": {
"remainSeat": [
{"seatGradeName": "VIP석", "remainCnt": 150},
{"seatGradeName": "R석", "remainCnt": 36},
]
}
},
raise_for_status=lambda: None,
)
seats = client.get_seats("26000541", "055")
self.assertEqual(
seats,
[
{"seatGradeName": "VIP석", "remainCnt": 150},
{"seatGradeName": "R석", "remainCnt": 36},
],
)
def test_schedule_normalizes_date_and_time_format(self):
client = InterparkClient.__new__(InterparkClient)
client.http = mock.Mock()
client.http.get.return_value = mock.Mock(
json=lambda: {
"data": [
{"playDate": "20260513", "playTime": "1430", "playSeq": "055"}
],
},
raise_for_status=lambda: None,
)
out = client.schedule("26000541")
self.assertEqual(
out,
[{"date": "2026-05-13", "time": "14:30", "play_seq": "055"}],
)
class EndpointSafetyTest(unittest.TestCase):
def test_no_login_or_auth_headers(self):
for hdr in (HEADERS_YES24, HEADERS_INTERPARK):
self.assertNotIn("Cookie", hdr)
self.assertNotIn("Authorization", hdr)
self.assertNotIn("X-Auth-Token", hdr)
def test_bases_are_known_public_hosts(self):
self.assertEqual(YES24_BASE, "https://ticket.yes24.com")
self.assertEqual(INTERPARK_BASE, "https://api-ticketfront.interpark.com")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""ticket-availability — YES24 / 인터파크 공연 일정 + 잔여석 조회 CLI.
조회 전용. 예매·결제·로그인 자동화 없음.
공연법 §4조의2 (매크로 입장권 부정구매·판매 금지) 비적용.
Usage:
ticket-availability schedule <url>
ticket-availability seats <url> [--all-dates]
ticket-availability health
Supported URLs:
YES24: https://ticket.yes24.com/Perf/<perf_id>
https://ticket.yes24.com/New/Perf/Detail/View/<perf_id>
yes24:<perf_id>
인터파크: https://tickets.interpark.com/goods/<goods_code>
interpark:<goods_code>
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime
from typing import Any
try:
import httpx
except ModuleNotFoundError: # pragma: no cover - depends on user environment
httpx = None
class MissingHttpxError(RuntimeError):
"""Raised when the optional httpx runtime dependency is unavailable."""
def _require_httpx():
if httpx is None:
raise MissingHttpxError(
"Python package 'httpx' is required. Install it with: python3 -m pip install httpx"
)
return httpx
HTTPX_HTTP_ERROR = (
getattr(httpx, "HTTPError", MissingHttpxError) if httpx else MissingHttpxError
)
# ── URL Parsing ───────────────────────────────────────────────────────────────
def parse_url(url: str) -> tuple[str, str]:
"""Return (platform, id). Accepts full URL or `platform:id` shorthand."""
if url.startswith("yes24:"):
return "yes24", url[6:]
if url.startswith("interpark:"):
return "interpark", url[10:]
m = re.search(
r"yes24\.com/(?:[Nn]ew/)?[Pp]erf/(?:[Dd]etail/)?(?:[Vv]iew/)?(\d+)", url
)
if m:
return "yes24", m.group(1)
m = re.search(r"interpark\.com/goods/(\d+)", url, re.IGNORECASE)
if m:
return "interpark", m.group(1)
if re.fullmatch(r"\d+", url):
raise ValueError(
f"플랫폼을 명시하세요: yes24:{url} 또는 interpark:{url}"
)
raise ValueError(f"URL을 인식할 수 없습니다: {url}")
def _fmt_date(d: str) -> str:
if d and len(d) == 8 and d.isdigit():
return f"{d[:4]}-{d[4:6]}-{d[6:]}"
return d
def _fmt_time(t: str) -> str:
if t and len(t) == 4 and t.isdigit():
return f"{t[:2]}:{t[2:]}"
return t
# ── HTTP Setup ────────────────────────────────────────────────────────────────
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"Chrome/124.0.0.0 Safari/537.36"
)
HEADERS_YES24 = {
"User-Agent": UA,
"Referer": "https://ticket.yes24.com/",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
}
HEADERS_INTERPARK = {
"User-Agent": UA,
"Referer": "https://tickets.interpark.com/",
"Accept": "application/json",
}
YES24_BASE = "https://ticket.yes24.com"
INTERPARK_BASE = "https://api-ticketfront.interpark.com"
# ── YES24 Client ──────────────────────────────────────────────────────────────
class Yes24Client:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_YES24, timeout=20, follow_redirects=True
)
def _dates(self, perf_id: str, month_count: int) -> list[str]:
now = datetime.now()
months: list[str] = []
for delta in range(month_count):
month = now.month + delta
year = now.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
months.append(f"{year:04d}-{month:02d}")
dates: list[str] = []
cutoff = now.strftime("%Y%m%d")
for month_str in months:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx",
data={
"pGetMode": "days",
"pIdPerf": perf_id,
"pPerfMonth": month_str,
"pIdCode": "",
"pIsMania": "0",
},
)
r.raise_for_status()
text = r.text.strip().strip(",")
if not text:
continue
for raw in text.split(","):
d = raw.strip()
if not d:
continue
normalized = d.replace("-", "")
if normalized >= cutoff:
dates.append(normalized)
return sorted(set(dates))
def get_dates(self, perf_id: str) -> list[str]:
"""Available dates within ~3 weeks (fast)."""
return self._dates(perf_id, month_count=3)
def get_all_dates(self, perf_id: str) -> list[str]:
"""Available dates across 6 months (full schedule)."""
return self._dates(perf_id, month_count=6)
def get_slots(self, perf_id: str, perf_day: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/NEw/Perf/Detail/Ajax/axPerfPlayTime.aspx",
data={"IdPerf": perf_id, "PerfDay": perf_day},
)
r.raise_for_status()
html = r.text
slots: list[dict] = []
seen: set[str] = set()
for m in re.finditer(r"idTime='(\d+)'", html):
id_time = m.group(1)
if id_time in seen:
continue
seen.add(id_time)
ctx_start = max(0, m.start() - 200)
ctx = html[ctx_start : m.end() + 200]
time_m = re.search(r"(\d{1,2}:\d{2}|\d[회]|[12]\d{3}회)", ctx)
label = time_m.group(0) if time_m else id_time
slots.append({"idTime": id_time, "label": label})
return slots
def get_seats(self, id_time: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Detail/Ajax/axPerfRemainSeat.aspx",
data={"Type": "calendar", "IdTime": id_time, "IdLock": "0"},
)
r.raise_for_status()
html = r.text
seats: list[dict] = []
for m in re.finditer(
r"<dt>([^<]+)</dt>\s*<dd>([^<]*)<span[^>]*>\(잔여:(\d+)석\)</span>",
html,
):
seats.append(
{
"grade": m.group(1).strip(),
"price": m.group(2).strip().rstrip(",").strip(),
"remain": int(m.group(3)),
}
)
if not seats:
for i, m in enumerate(re.finditer(r"\(잔여:(\d+)석\)", html)):
seats.append({"grade": f"좌석{i+1}", "price": "", "remain": int(m.group(1))})
return seats
def schedule(self, perf_id: str, all_dates: bool) -> list[dict]:
"""Schedule = dates × slots flattened. No seat lookup."""
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
out: list[dict] = []
for d in dates:
for slot in self.get_slots(perf_id, d):
out.append(
{
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
}
)
return out
def all_seats(self, perf_id: str, all_dates: bool) -> dict:
result: dict = {}
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
for d in dates:
for slot in self.get_slots(perf_id, d):
seats = self.get_seats(slot["idTime"])
key = f"{_fmt_date(d)}|{slot['label']}"
result[key] = {
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
"seats": seats,
}
time.sleep(0.4)
return result
# ── Interpark Client ──────────────────────────────────────────────────────────
class InterparkClient:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_INTERPARK, timeout=20, follow_redirects=True
)
def get_schedule(self, goods_code: str) -> list[dict]:
now = datetime.now()
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq",
params={
"goodsCode": goods_code,
"isBookableDate": "true",
"page": "1",
"pageSize": "200",
"startDate": now.strftime("%Y%m%d"),
"endDate": f"{now.year + 1}{now.month:02d}{now.day:02d}",
},
)
r.raise_for_status()
data = r.json()
if isinstance(data, list):
return data
return data.get("response", {}).get("data") or data.get("data") or []
def get_seats(self, goods_code: str, play_seq: str) -> list[dict]:
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq/PlaySeq/{play_seq}/REMAINSEAT"
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict):
return (
data.get("remainSeat")
or (data.get("data") or {}).get("remainSeat")
or data.get("response", {}).get("remainSeat")
or []
)
return []
def schedule(self, goods_code: str) -> list[dict]:
out: list[dict] = []
for item in self.get_schedule(goods_code):
out.append(
{
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": item.get("playSeq", ""),
}
)
return out
def all_seats(self, goods_code: str) -> dict:
result: dict = {}
for item in self.get_schedule(goods_code):
seq = item.get("playSeq", "")
if not seq:
continue
seats_raw = self.get_seats(goods_code, seq)
normalized = [
{
"grade": s.get("seatGradeName", s.get("seatGrade", "")),
"remain": int(s.get("remainCnt", 0)),
}
for s in seats_raw
]
key = f"{_fmt_date(item.get('playDate', ''))}|{_fmt_time(item.get('playTime', ''))}|{seq}"
result[key] = {
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": seq,
"seats": normalized,
}
time.sleep(0.3)
return result
# ── CLI ───────────────────────────────────────────────────────────────────────
def _dump(obj: Any, compact: bool) -> str:
if compact:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
return json.dumps(obj, ensure_ascii=False, indent=2)
def cmd_schedule(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().schedule(pid, all_dates=args.all_dates)
else:
out = InterparkClient().schedule(pid)
print(_dump({"platform": platform, "id": pid, "schedule": out}, args.compact))
return 0
def cmd_seats(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().all_seats(pid, all_dates=args.all_dates)
else:
out = InterparkClient().all_seats(pid)
print(_dump({"platform": platform, "id": pid, "seats": out}, args.compact))
return 0
def cmd_health(args: argparse.Namespace) -> int:
http = _require_httpx()
results: dict = {}
for name, url in [
("yes24",
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx"),
("interpark",
f"{INTERPARK_BASE}/v1/goods/00000000/playSeq"),
]:
try:
if name == "yes24":
r = http.post(url, headers=HEADERS_YES24,
data={"pGetMode": "days", "pIdPerf": "0",
"pPerfMonth": "2000-01", "pIdCode": "",
"pIsMania": "0"}, timeout=10)
else:
r = http.get(url, headers=HEADERS_INTERPARK,
params={"goodsCode": "00000000",
"isBookableDate": "true",
"page": "1", "pageSize": "1",
"startDate": "20000101",
"endDate": "20000102"},
timeout=10)
results[name] = {"status": r.status_code, "ok": r.status_code < 500}
except Exception as e:
results[name] = {"status": 0, "ok": False, "error": str(e)}
print(_dump(results, args.compact))
return 0 if all(v.get("ok") for v in results.values()) else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="ticket-availability",
description="YES24 / 인터파크 공연 일정 + 잔여석 조회 (조회 전용)",
)
sub = parser.add_subparsers(dest="cmd", required=True)
def _common(p: argparse.ArgumentParser) -> None:
p.add_argument("--compact", action="store_true",
help="One-line JSON (기본: 들여쓰기 출력)")
p_sch = sub.add_parser("schedule", help="공연 일정 조회")
p_sch.add_argument("url", help="공연 URL 또는 platform:id")
p_sch.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_sch)
p_sch.set_defaults(func=cmd_schedule)
p_st = sub.add_parser("seats", help="등급별 잔여석 조회 (전 일정)")
p_st.add_argument("url", help="공연 URL 또는 platform:id")
p_st.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_st)
p_st.set_defaults(func=cmd_seats)
p_h = sub.add_parser("health", help="API endpoint reachability check")
_common(p_h)
p_h.set_defaults(func=cmd_health)
args = parser.parse_args(argv)
try:
return args.func(args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except MissingHttpxError as e:
print(f"dependency error: {e}", file=sys.stderr)
return 4
except HTTPX_HTTP_ERROR as e:
print(f"http error: {e}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())

121
seoul-density/SKILL.md Normal file
View file

@ -0,0 +1,121 @@
---
name: seoul-density
description: 서울 주요 121개 핫스팟 장소의 실시간 혼잡도와 인구 현황을 조회한다. 지금 강남역이 얼마나 붐비는지, 홍대 인파가 얼마나 되는지 물어볼 때 사용한다.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
---
# Seoul Density
## What this skill does
서울 실시간 도시데이터 API(data.seoul.go.kr)를 호출해 121개 핫스팟의 **현재 혼잡도 단계**(여유 / 보통 / 약간 붐빔 / 붐빔)와 **추정 인구 범위**를 반환한다.
데이터는 KT·SKT 통신 신호 기반 추계치이며, 5분 주기로 갱신되나 호출 시점 기준 약 15분 전 값이다.
## When to use
- "지금 강남역 얼마나 붐벼?"
- "홍대 지금 인파 어때?"
- "명동 지금 사람 많아?"
- "여의도한강공원 지금 여유로워?"
## Prerequisites
별도 API 키 발급 없이 그대로 쓸 수 있다. 모든 호출은 **k-skill-proxy 경유**다.
- 기본 프록시 URL: `https://k-skill-proxy.nomadamas.org` — 프록시 서버가 `SEOUL_OPEN_API_KEY`를 보유하고 있어 사용자는 키 없이 호출만 하면 된다.
- `KSKILL_PROXY_BASE_URL` 환경변수로 프록시 주소를 바꿀 수 있다(예: 로컬 개발용 `http://127.0.0.1:4020`).
## Single entrypoint
이 스킬의 모든 동작은 **단일 진입점**을 통한다. OS·CWD에 관계없이 동일하게 동작하도록 절대 경로 + Python launcher fallback을 사용한다:
```bash
# macOS / Linux / Git-bash
python3 "$SKILL_DIR/scripts/seoul_density.py" <subcommand> [args]
# Windows (PowerShell): py 런처 또는 python
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" <subcommand> [args]
```
`$SKILL_DIR`은 이 SKILL.md가 위치한 디렉토리다(`~/.claude/skills/seoul-density` 또는 레포의 `seoul-density/`). 호출 예시는 아래 Workflow 참조.
첫 사용 시 `Bash(python3 *seoul_density.py:*)` (또는 PowerShell 환경에서 `PowerShell(py -3 *seoul_density.py*)`) 패턴 한 번만 승인하면 이후 호출은 모두 자동 허용된다. 외부 dependency는 없고 Python 표준 라이브러리만 사용한다.
### Subcommands
| 명령 | 설명 |
|------|------|
| `list [--json]` | 지원 121개 장소 목록 (카테고리별) |
| `match <키워드> [--limit N] [--json]` | 사용자 입력 → 지원 장소명 매칭 |
| `query <장소명> [--json]` | 실시간 혼잡도/인구 조회 (사람이 읽는 요약 또는 JSON) |
## Workflow
### 1. 모호한 입력은 match로 후보 확인 (선택)
사용자가 "홍대 인파"처럼 모호하게 말하면 먼저 후보를 확인한다.
```bash
python3 "$SKILL_DIR/scripts/seoul_density.py" match "홍대" --json
# → ["홍대 관광특구", "홍대입구역(2호선)"]
```
후보가 1개면 바로 `query`로 넘어가도 되고(스크립트가 자동 매칭), 여러 개면 어느 쪽인지 사용자에게 확인한다.
### 2. 혼잡도 조회
키워드 1개만 매칭되면 자동으로 보정한다.
```bash
# macOS / Linux / Git-bash
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역"
# Windows PowerShell
py -3 "$env:SKILL_DIR\scripts\seoul_density.py" query "강남역"
```
출력 예시:
```
장소: 강남역
혼잡도: 약간 붐빔
인구 추정: 24000~26000명
기준 시각: 2026-05-14 09:30
상황: 사람이 몰려있을 수 있어요
```
기계적 후처리가 필요하면 `--json` 플래그를 쓴다:
```bash
python3 "$SKILL_DIR/scripts/seoul_density.py" query "강남역" --json
```
자동 매칭을 끄고 싶으면 `--no-auto`를 쓴다.
## Done when
- 장소명, 혼잡도 단계, 추정 인구 범위(최소~최대), 기준 시각, 혼잡도 메시지를 사용자에게 전달했다.
## Failure modes
| 상황 | 동작 |
|------|------|
| 프록시 정상 응답 | 별도 키 불필요, 즉시 결과 반환 |
| 지원하지 않는 장소명 (`exit 1`) | `match` 결과로 후보 제안 |
| 프록시 HTTP/네트워크 오류 (`exit 1`) | stderr에 사유 출력, `KSKILL_PROXY_BASE_URL` 점검 또는 5분 후 재시도 안내 |
| 새벽 01~05시 빈 응답 | 실시간 데이터 미제공 시간대임을 안내 |
| 일일 할당량 초과 | 다음 날 재시도 안내 |
## Notes
- 인구 수치는 실제값이 아닌 **추계치** (KT·SKT 통신 신호 데이터 기반).
- 데이터는 호출 시점 기준 **약 15분 전** 값.
- 단일 진입점 외에 `curl`, `python3 -c`, `source` 같은 inline 명령을 직접 실행하지 말 것. 그렇게 하면 사용자가 매번 별도 승인을 받아야 한다.
- 새 카테고리/장소가 추가되면 `seoul-density/scripts/seoul_density.py``AREAS` 딕셔너리만 갱신한다.

View file

@ -0,0 +1,259 @@
"""Single-entrypoint CLI for the seoul-density skill.
All skill operations route through `python3 seoul-density/scripts/seoul_density.py <subcommand>`
so users only have to approve one Bash pattern on first use.
Subcommands:
list print supported area names grouped by category
match <keyword> fuzzy-match a user keyword to a supported area name
query <area-name> [--json] fetch and summarize real-time density for the area
"""
from __future__ import annotations
import argparse
import difflib
import json
import os
import sys
import urllib.error
import urllib.request
import urllib.parse
from typing import Any
for _stream in (sys.stdout, sys.stderr):
reconfigure = getattr(_stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8")
except (OSError, ValueError):
pass
AREAS: dict[str, list[str]] = {
"고궁·문화유산": [
"경복궁", "광화문·덕수궁", "보신각", "서울 암사동 유적", "창덕궁·종묘",
],
"관광특구": [
"강남 MICE 관광특구", "동대문 관광특구", "명동 관광특구", "이태원 관광특구",
"잠실 관광특구", "종로·청계 관광특구", "홍대 관광특구",
],
"공원": [
"강서한강공원", "고척돔", "광나루한강공원", "광화문광장",
"국립중앙박물관·용산가족공원", "난지한강공원", "남산공원", "노들섬",
"뚝섬한강공원", "망원한강공원", "반포한강공원", "보라매공원",
"북서울꿈의숲", "서대문독립공원", "서리풀공원·몽마르뜨공원", "서울대공원",
"서울숲공원", "송현녹지광장", "아차산", "안양천", "양화한강공원",
"어린이대공원", "여의도한강공원", "여의서로", "올림픽공원", "월드컵공원",
"응봉산", "이촌한강공원", "잠실종합운동장", "잠실한강공원", "잠원한강공원",
"청계산", "홍제폭포",
],
"발달상권": [
"가락시장", "가로수길", "광장(전통)시장", "김포공항", "남대문시장", "노량진",
"덕수궁길·정동길", "북창동 먹자골목", "북촌한옥마을", "서촌", "성수카페거리",
"송리단길·호수단길", "신촌 스타광장", "압구정로데오거리", "여의도", "연남동",
"영등포 타임스퀘어", "용리단길", "이태원 앤틱가구거리", "익선동", "인사동",
"잠실롯데타워·석촌호수", "창동 신경제 중심지", "청담동 명품거리",
"청량리 제기동 일대 전통시장", "해방촌·경리단길", "DDP(동대문디자인플라자)",
"DMC(디지털미디어시티)",
],
"인구밀집지역": [
"가산디지털단지역", "강남역", "건대입구역", "고덕역", "고속터미널역", "교대역",
"구로디지털단지역", "구로역", "군자역", "대림역", "동대문역", "뚝섬역",
"미아사거리역", "발산역", "사당역", "삼각지역", "서울대입구역",
"서울식물원·마곡나루역", "서울역", "성신여대입구역", "선릉역", "시의회 앞",
"수유역", "신논현역·논현역", "신도림역", "신림역", "신촌·이대역", "쌍문역",
"신정네거리역", "역삼역", "연신내역", "양재역", "왕십리역", "용산역",
"오목교역·목동운동장", "잠실새내역", "잠실역", "장지역", "장한평역", "천호역",
"총신대입구(이수)역", "충정로역", "합정역", "혜화역", "홍대입구역(2호선)",
"회기역",
],
}
TIMEOUT_SEC = 10
PROXY_BASE_URL_NAME = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
def all_areas() -> list[str]:
return [name for group in AREAS.values() for name in group]
def cmd_list(args: argparse.Namespace) -> int:
if args.json:
json.dump(AREAS, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
for category, names in AREAS.items():
print(f"## {category} ({len(names)}곳)")
print(", ".join(names))
print()
return 0
def _normalize(text: str) -> str:
"""Strip whitespace and common location suffixes for loose matching."""
cleaned = "".join(ch for ch in text if not ch.isspace())
for suffix in ("관광특구", "한강공원", "공원", "시장", "", "거리", "광장"):
if cleaned.endswith(suffix) and len(cleaned) > len(suffix):
cleaned = cleaned[: -len(suffix)]
break
return cleaned
def fuzzy_match(keyword: str, limit: int = 5) -> list[str]:
names = all_areas()
keyword = keyword.strip()
if not keyword:
return []
exact = [n for n in names if keyword in n]
if exact:
return exact[:limit]
contained = [n for n in names if n in keyword]
if contained:
return contained[:limit]
norm_kw = _normalize(keyword)
if norm_kw:
loose = [n for n in names if norm_kw and (norm_kw in _normalize(n) or _normalize(n) in norm_kw)]
if loose:
return loose[:limit]
return difflib.get_close_matches(keyword, names, n=limit, cutoff=0.3)
def cmd_match(args: argparse.Namespace) -> int:
matches = fuzzy_match(args.keyword, limit=args.limit)
if not matches:
print(f"'{args.keyword}'와 일치하는 지원 장소가 없습니다.", file=sys.stderr)
print("'python3 seoul-density/scripts/seoul_density.py list' 로 전체 목록을 확인하세요.", file=sys.stderr)
return 1
if args.json:
json.dump(matches, sys.stdout, ensure_ascii=False)
sys.stdout.write("\n")
else:
for name in matches:
print(name)
return 0
def get_proxy_base_url() -> str:
value = os.environ.get(PROXY_BASE_URL_NAME)
if value and value != "replace-me":
return value.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def fetch_density_via_proxy(area: str) -> dict[str, Any]:
base_url = get_proxy_base_url()
query = urllib.parse.urlencode({"area": area})
url = f"{base_url}/v1/seoul-density/citydata?{query}"
req = urllib.request.Request(url, headers={"User-Agent": "k-skill/seoul-density"})
with urllib.request.urlopen(req, timeout=TIMEOUT_SEC) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw)
def summarize(payload: dict[str, Any]) -> dict[str, Any]:
result = payload.get("RESULT") or {}
code = result.get("RESULT.CODE")
message = result.get("RESULT.MESSAGE", "")
if code and code != "INFO-000":
raise RuntimeError(f"API 오류: {code} {message}".strip())
rows = payload.get("SeoulRtd.citydata_ppltn") or []
if not rows:
raise RuntimeError("인구 데이터가 없습니다. 장소명을 'match' 서브커맨드로 확인하세요.")
row = rows[0]
return {
"area": row.get("AREA_NM"),
"congestion_level": row.get("AREA_CONGEST_LVL"),
"population_min": row.get("AREA_PPLTN_MIN"),
"population_max": row.get("AREA_PPLTN_MAX"),
"as_of": row.get("PPLTN_TIME"),
"message": row.get("AREA_CONGEST_MSG"),
}
def cmd_query(args: argparse.Namespace) -> int:
area = args.area.strip()
if area not in all_areas():
suggestions = fuzzy_match(area, limit=3)
if len(suggestions) == 1 and getattr(args, "auto", True):
print(f"'{area}''{suggestions[0]}' 로 자동 매칭", file=sys.stderr)
area = suggestions[0]
else:
hint = (
f" 가까운 후보: {', '.join(suggestions)}" if suggestions else ""
)
print(f"지원하지 않는 장소: {area}{hint}", file=sys.stderr)
return 1
try:
payload = fetch_density_via_proxy(area)
summary = summarize(payload)
except urllib.error.HTTPError as exc:
print(f"API HTTP 오류: {exc.code} {exc.reason}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"API 연결 실패: {exc.reason}", file=sys.stderr)
return 1
except (RuntimeError, json.JSONDecodeError) as exc:
print(str(exc), file=sys.stderr)
return 1
if args.json:
json.dump(summary, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
return 0
print(f"장소: {summary['area']}")
print(f"혼잡도: {summary['congestion_level']}")
print(f"인구 추정: {summary['population_min']}~{summary['population_max']}")
print(f"기준 시각: {summary['as_of'] or '알 수 없음'}")
print(f"상황: {summary['message']}")
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="seoul_density",
description="서울 실시간 도시데이터(혼잡도/인구) 단일 진입점 CLI",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_list = sub.add_parser("list", help="지원 장소 목록 출력")
p_list.add_argument("--json", action="store_true")
p_list.set_defaults(func=cmd_list)
p_match = sub.add_parser("match", help="키워드 → 지원 장소명 매칭")
p_match.add_argument("keyword")
p_match.add_argument("--limit", type=int, default=5)
p_match.add_argument("--json", action="store_true")
p_match.set_defaults(func=cmd_match)
p_query = sub.add_parser("query", help="장소 혼잡도 조회")
p_query.add_argument("area", help="지원 장소명 (목록은 'list' 참조)")
p_query.add_argument("--json", action="store_true")
p_query.add_argument(
"--no-auto",
dest="auto",
action="store_false",
help="후보가 1개뿐이어도 자동 매칭하지 않음",
)
p_query.set_defaults(func=cmd_query, auto=True)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,186 @@
---
name: ticket-availability
description: YES24 / 인터파크 공연의 공개 일정 + 등급별 잔여석을 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음).
license: MIT
metadata:
category: lifestyle
subcategory: ticket
locale: ko-KR
phase: v1
---
# Ticket Availability
## What this skill does
YES24 (`ticket.yes24.com`) 와 인터파크 (`tickets.interpark.com`) 의 공개 BFF JSON / Ajax endpoint 를 단일 HTTP 요청으로 호출해 공연 일정과 등급별 잔여석 수를 정규화한다.
- 공연 URL 또는 `platform:id` 표기로 입력을 받는다.
- 일정 (날짜·시간·회차) 조회.
- 등급별 잔여석 수 조회 (등급명, 잔여수, YES24의 경우 노출가).
- 좌석맵 / 좌석 선택 / 예매 / 결제 / 로그인 세션 접근은 하지 않는다.
- CloakBrowser, Playwright, fingerprint spoofing, CAPTCHA 우회를 사용하지 않는다 (`httpx` only).
## When to use
- "오늘 인터파크 ○○ 공연 잔여석 있어?"
- "YES24 콘서트 ID 58026 일정 알려줘"
- "이 공연 R석 몇 자리 남았어?"
- "공연 URL 줄게, 회차별 잔여석 확인해줘"
## When not to use
- 예매·결제·취소·환불 처리 — **공연법 §4조의2 (2023.9 시행) 매크로 입장권 부정구매·판매 금지** 대상이며 이 스킬은 의도적으로 예매를 지원하지 않는다.
- 좌석 선택, 좌석맵 시각화, 특정 좌석 번호 확인 — 잔여 ** 만 노출한다.
- 회원 등급별 우선 예매, 쿠폰가, 카드사 할인가 — 공개 endpoint 만 사용한다.
- 차단 우회, CAPTCHA 우회, headless 감지 우회 — `httpx` 한 호출로 안 되면 실패 모드로 처리하고 종료한다.
## Required inputs
공연 URL 또는 `platform:id` 표기가 없으면 먼저 물어본다.
권장 질문:
> 확인하실 공연의 YES24 또는 인터파크 URL을 알려주세요.
> 예: `https://tickets.interpark.com/goods/26000541`
> `https://ticket.yes24.com/Perf/58026`
## Prerequisites
- Python 3.9+
- `httpx` (표준 패키지)
설치:
```bash
pip install httpx
```
## Workflow
### 1. URL 파싱
| 입력 | 매칭 |
|---|---|
| `https://tickets.interpark.com/goods/<goods_code>` | platform=interpark |
| `https://ticket.yes24.com/Perf/<perf_id>` | platform=yes24 |
| `https://ticket.yes24.com/New/Perf/Detail/View/<perf_id>` | platform=yes24 |
| `yes24:<id>` / `interpark:<id>` | shorthand |
### 2. 일정 조회 (`schedule`)
```bash
python3 scripts/ticket_availability.py schedule "https://tickets.interpark.com/goods/26000541"
```
응답 — Interpark:
```json
{
"platform": "interpark",
"id": "26000541",
"schedule": [
{"date": "2026-05-13", "time": "14:30", "play_seq": "055"},
{"date": "2026-05-14", "time": "19:30", "play_seq": "057"}
]
}
```
응답 — YES24:
```json
{
"platform": "yes24",
"id": "58026",
"schedule": [
{"date": "2026-05-16", "time_label": "1회", "id_time": "1432397"}
]
}
```
YES24 는 기본 3주 윈도우. 6개월 전체는 `--all-dates` 추가.
### 3. 잔여석 조회 (`seats`)
```bash
python3 scripts/ticket_availability.py seats "interpark:26000541"
```
응답:
```json
{
"platform": "interpark",
"id": "26000541",
"seats": {
"2026-05-13|14:30|055": {
"date": "2026-05-13", "time": "14:30", "play_seq": "055",
"seats": [
{"grade": "VIP석", "remain": 150},
{"grade": "R석", "remain": 36},
{"grade": "S석", "remain": 82},
{"grade": "A석", "remain": 71}
]
}
}
}
```
YES24 응답은 등급별 `price` (노출가) 도 포함:
```json
{"grade": "전석", "price": "110,000원", "remain": 2}
```
### 4. 헬스체크 (`health`)
```bash
python3 scripts/ticket_availability.py health
```
응답:
```json
{"yes24": {"status": 200, "ok": true}, "interpark": {"status": 200, "ok": true}}
```
## Output format
기본 출력은 들여쓰기 JSON. 파이프/스크립트용은 `--compact` 추가 (한 줄 JSON).
## Endpoints used
이 스킬이 호출하는 공개 endpoint 만:
| Platform | Method | URL |
|---|---|---|
| YES24 | POST | `https://ticket.yes24.com/New/Perf/Sale/Ajax/axPerfDay.aspx` |
| YES24 | POST | `https://ticket.yes24.com/NEw/Perf/Detail/Ajax/axPerfPlayTime.aspx` |
| YES24 | POST | `https://ticket.yes24.com/New/Perf/Detail/Ajax/axPerfRemainSeat.aspx` |
| Interpark | GET | `https://api-ticketfront.interpark.com/v1/goods/<id>/playSeq` |
| Interpark | GET | `https://api-ticketfront.interpark.com/v1/goods/<id>/playSeq/PlaySeq/<seq>/REMAINSEAT` |
전부 비로그인 / 무인증. 헤더는 `User-Agent` + `Referer` + JSON `Accept` 만.
## Failure modes
- **YES24 `schedule` 결과 빈 배열**: 공연 ID 가 유효하지만 향후 3주(또는 6개월) 내 일정이 없음. ID 자체가 잘못된 경우와 구분되지 않으므로, 사용자에게 `--all-dates` 또는 다른 ID 확인을 안내한다.
- **Interpark `data: []`**: goods_code 가 지나갔거나 아직 오픈 전 / 비공개. 다른 ID 확인을 안내한다.
- **HTTP 4xx/5xx**: 차단/일시 장애. 우회 시도하지 않고 `http error` 출력 후 종료.
- **JSON 스키마 변경**: YES24 axPerfRemainSeat 는 HTML 응답을 정규식으로 파싱 — 사이트 갱신 시 영향 가능. `remain` 0 으로 잘못 보고될 수 있어 사용자에게 "조회 시각 기준" 이라고 표기.
- **공연 매진**: API 는 `remain: 0` 반환. 매진 표시.
## Response style
- 잔여석은 "조회 시각 기준" 으로 표현한다 (실시간 변동).
- "매크로", "선점", "오픈런", "자동 예매" 표현 금지 — 이 스킬은 조회 전용.
- 잔여석 수치 + 등급명 만 인용. 좌석 번호 / 좌석 위치는 노출하지 않는다.
- "지금 사라" 같은 행위 유도 금지 — 사용자가 직접 페이지에서 결제.
## Notes
- 본 스킬은 의도적으로 **예매 / 결제 / 좌석선택 / 로그인 자동화** 를 포함하지 않는다. 매크로를 통한 입장권 부정구매·판매는 공연법 §4조의2 (2023.9.22 시행) 에 의해 형사처벌 대상.
- 시크릿 / 키 / 로그인 세션 일체 사용하지 않는다.
- Rate limit: `seats` 명령은 회차별 순차 호출 — Interpark 0.3s, YES24 0.4s 간격. 100회차 짜리 공연이면 약 30s ~ 40s 소요. 짧은 모니터링 루프에 넣지 말 것.
## Done when
- 공연 URL 또는 `platform:id` 가 확인되었다.
- 일정 또는 잔여석 결과 JSON 을 반환하거나, 빈 결과 사유를 설명했다.
- 예매 / 결제 / 좌석 선택 기능을 자동화하지 않았다.
- 조회 시각 기준임을 안내했다.

View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""ticket-availability — YES24 / 인터파크 공연 일정 + 잔여석 조회 CLI.
조회 전용. 예매·결제·로그인 자동화 없음.
공연법 §4조의2 (매크로 입장권 부정구매·판매 금지) 비적용.
Usage:
ticket-availability schedule <url>
ticket-availability seats <url> [--all-dates]
ticket-availability health
Supported URLs:
YES24: https://ticket.yes24.com/Perf/<perf_id>
https://ticket.yes24.com/New/Perf/Detail/View/<perf_id>
yes24:<perf_id>
인터파크: https://tickets.interpark.com/goods/<goods_code>
interpark:<goods_code>
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from datetime import datetime
from typing import Any
try:
import httpx
except ModuleNotFoundError: # pragma: no cover - depends on user environment
httpx = None
class MissingHttpxError(RuntimeError):
"""Raised when the optional httpx runtime dependency is unavailable."""
def _require_httpx():
if httpx is None:
raise MissingHttpxError(
"Python package 'httpx' is required. Install it with: python3 -m pip install httpx"
)
return httpx
HTTPX_HTTP_ERROR = (
getattr(httpx, "HTTPError", MissingHttpxError) if httpx else MissingHttpxError
)
# ── URL Parsing ───────────────────────────────────────────────────────────────
def parse_url(url: str) -> tuple[str, str]:
"""Return (platform, id). Accepts full URL or `platform:id` shorthand."""
if url.startswith("yes24:"):
return "yes24", url[6:]
if url.startswith("interpark:"):
return "interpark", url[10:]
m = re.search(
r"yes24\.com/(?:[Nn]ew/)?[Pp]erf/(?:[Dd]etail/)?(?:[Vv]iew/)?(\d+)", url
)
if m:
return "yes24", m.group(1)
m = re.search(r"interpark\.com/goods/(\d+)", url, re.IGNORECASE)
if m:
return "interpark", m.group(1)
if re.fullmatch(r"\d+", url):
raise ValueError(
f"플랫폼을 명시하세요: yes24:{url} 또는 interpark:{url}"
)
raise ValueError(f"URL을 인식할 수 없습니다: {url}")
def _fmt_date(d: str) -> str:
if d and len(d) == 8 and d.isdigit():
return f"{d[:4]}-{d[4:6]}-{d[6:]}"
return d
def _fmt_time(t: str) -> str:
if t and len(t) == 4 and t.isdigit():
return f"{t[:2]}:{t[2:]}"
return t
# ── HTTP Setup ────────────────────────────────────────────────────────────────
UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"Chrome/124.0.0.0 Safari/537.36"
)
HEADERS_YES24 = {
"User-Agent": UA,
"Referer": "https://ticket.yes24.com/",
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
}
HEADERS_INTERPARK = {
"User-Agent": UA,
"Referer": "https://tickets.interpark.com/",
"Accept": "application/json",
}
YES24_BASE = "https://ticket.yes24.com"
INTERPARK_BASE = "https://api-ticketfront.interpark.com"
# ── YES24 Client ──────────────────────────────────────────────────────────────
class Yes24Client:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_YES24, timeout=20, follow_redirects=True
)
def _dates(self, perf_id: str, month_count: int) -> list[str]:
now = datetime.now()
months: list[str] = []
for delta in range(month_count):
month = now.month + delta
year = now.year + (month - 1) // 12
month = ((month - 1) % 12) + 1
months.append(f"{year:04d}-{month:02d}")
dates: list[str] = []
cutoff = now.strftime("%Y%m%d")
for month_str in months:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx",
data={
"pGetMode": "days",
"pIdPerf": perf_id,
"pPerfMonth": month_str,
"pIdCode": "",
"pIsMania": "0",
},
)
r.raise_for_status()
text = r.text.strip().strip(",")
if not text:
continue
for raw in text.split(","):
d = raw.strip()
if not d:
continue
normalized = d.replace("-", "")
if normalized >= cutoff:
dates.append(normalized)
return sorted(set(dates))
def get_dates(self, perf_id: str) -> list[str]:
"""Available dates within ~3 weeks (fast)."""
return self._dates(perf_id, month_count=3)
def get_all_dates(self, perf_id: str) -> list[str]:
"""Available dates across 6 months (full schedule)."""
return self._dates(perf_id, month_count=6)
def get_slots(self, perf_id: str, perf_day: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/NEw/Perf/Detail/Ajax/axPerfPlayTime.aspx",
data={"IdPerf": perf_id, "PerfDay": perf_day},
)
r.raise_for_status()
html = r.text
slots: list[dict] = []
seen: set[str] = set()
for m in re.finditer(r"idTime='(\d+)'", html):
id_time = m.group(1)
if id_time in seen:
continue
seen.add(id_time)
ctx_start = max(0, m.start() - 200)
ctx = html[ctx_start : m.end() + 200]
time_m = re.search(r"(\d{1,2}:\d{2}|\d[회]|[12]\d{3}회)", ctx)
label = time_m.group(0) if time_m else id_time
slots.append({"idTime": id_time, "label": label})
return slots
def get_seats(self, id_time: str) -> list[dict]:
r = self.http.post(
f"{YES24_BASE}/New/Perf/Detail/Ajax/axPerfRemainSeat.aspx",
data={"Type": "calendar", "IdTime": id_time, "IdLock": "0"},
)
r.raise_for_status()
html = r.text
seats: list[dict] = []
for m in re.finditer(
r"<dt>([^<]+)</dt>\s*<dd>([^<]*)<span[^>]*>\(잔여:(\d+)석\)</span>",
html,
):
seats.append(
{
"grade": m.group(1).strip(),
"price": m.group(2).strip().rstrip(",").strip(),
"remain": int(m.group(3)),
}
)
if not seats:
for i, m in enumerate(re.finditer(r"\(잔여:(\d+)석\)", html)):
seats.append({"grade": f"좌석{i+1}", "price": "", "remain": int(m.group(1))})
return seats
def schedule(self, perf_id: str, all_dates: bool) -> list[dict]:
"""Schedule = dates × slots flattened. No seat lookup."""
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
out: list[dict] = []
for d in dates:
for slot in self.get_slots(perf_id, d):
out.append(
{
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
}
)
return out
def all_seats(self, perf_id: str, all_dates: bool) -> dict:
result: dict = {}
dates = self.get_all_dates(perf_id) if all_dates else self.get_dates(perf_id)
for d in dates:
for slot in self.get_slots(perf_id, d):
seats = self.get_seats(slot["idTime"])
key = f"{_fmt_date(d)}|{slot['label']}"
result[key] = {
"date": _fmt_date(d),
"time_label": slot["label"],
"id_time": slot["idTime"],
"seats": seats,
}
time.sleep(0.4)
return result
# ── Interpark Client ──────────────────────────────────────────────────────────
class InterparkClient:
def __init__(self) -> None:
http = _require_httpx()
self.http = http.Client(
headers=HEADERS_INTERPARK, timeout=20, follow_redirects=True
)
def get_schedule(self, goods_code: str) -> list[dict]:
now = datetime.now()
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq",
params={
"goodsCode": goods_code,
"isBookableDate": "true",
"page": "1",
"pageSize": "200",
"startDate": now.strftime("%Y%m%d"),
"endDate": f"{now.year + 1}{now.month:02d}{now.day:02d}",
},
)
r.raise_for_status()
data = r.json()
if isinstance(data, list):
return data
return data.get("response", {}).get("data") or data.get("data") or []
def get_seats(self, goods_code: str, play_seq: str) -> list[dict]:
r = self.http.get(
f"{INTERPARK_BASE}/v1/goods/{goods_code}/playSeq/PlaySeq/{play_seq}/REMAINSEAT"
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict):
return (
data.get("remainSeat")
or (data.get("data") or {}).get("remainSeat")
or data.get("response", {}).get("remainSeat")
or []
)
return []
def schedule(self, goods_code: str) -> list[dict]:
out: list[dict] = []
for item in self.get_schedule(goods_code):
out.append(
{
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": item.get("playSeq", ""),
}
)
return out
def all_seats(self, goods_code: str) -> dict:
result: dict = {}
for item in self.get_schedule(goods_code):
seq = item.get("playSeq", "")
if not seq:
continue
seats_raw = self.get_seats(goods_code, seq)
normalized = [
{
"grade": s.get("seatGradeName", s.get("seatGrade", "")),
"remain": int(s.get("remainCnt", 0)),
}
for s in seats_raw
]
key = f"{_fmt_date(item.get('playDate', ''))}|{_fmt_time(item.get('playTime', ''))}|{seq}"
result[key] = {
"date": _fmt_date(item.get("playDate", "")),
"time": _fmt_time(item.get("playTime", "")),
"play_seq": seq,
"seats": normalized,
}
time.sleep(0.3)
return result
# ── CLI ───────────────────────────────────────────────────────────────────────
def _dump(obj: Any, compact: bool) -> str:
if compact:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
return json.dumps(obj, ensure_ascii=False, indent=2)
def cmd_schedule(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().schedule(pid, all_dates=args.all_dates)
else:
out = InterparkClient().schedule(pid)
print(_dump({"platform": platform, "id": pid, "schedule": out}, args.compact))
return 0
def cmd_seats(args: argparse.Namespace) -> int:
platform, pid = parse_url(args.url)
if platform == "yes24":
out = Yes24Client().all_seats(pid, all_dates=args.all_dates)
else:
out = InterparkClient().all_seats(pid)
print(_dump({"platform": platform, "id": pid, "seats": out}, args.compact))
return 0
def cmd_health(args: argparse.Namespace) -> int:
http = _require_httpx()
results: dict = {}
for name, url in [
("yes24",
f"{YES24_BASE}/New/Perf/Sale/Ajax/axPerfDay.aspx"),
("interpark",
f"{INTERPARK_BASE}/v1/goods/00000000/playSeq"),
]:
try:
if name == "yes24":
r = http.post(url, headers=HEADERS_YES24,
data={"pGetMode": "days", "pIdPerf": "0",
"pPerfMonth": "2000-01", "pIdCode": "",
"pIsMania": "0"}, timeout=10)
else:
r = http.get(url, headers=HEADERS_INTERPARK,
params={"goodsCode": "00000000",
"isBookableDate": "true",
"page": "1", "pageSize": "1",
"startDate": "20000101",
"endDate": "20000102"},
timeout=10)
results[name] = {"status": r.status_code, "ok": r.status_code < 500}
except Exception as e:
results[name] = {"status": 0, "ok": False, "error": str(e)}
print(_dump(results, args.compact))
return 0 if all(v.get("ok") for v in results.values()) else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="ticket-availability",
description="YES24 / 인터파크 공연 일정 + 잔여석 조회 (조회 전용)",
)
sub = parser.add_subparsers(dest="cmd", required=True)
def _common(p: argparse.ArgumentParser) -> None:
p.add_argument("--compact", action="store_true",
help="One-line JSON (기본: 들여쓰기 출력)")
p_sch = sub.add_parser("schedule", help="공연 일정 조회")
p_sch.add_argument("url", help="공연 URL 또는 platform:id")
p_sch.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_sch)
p_sch.set_defaults(func=cmd_schedule)
p_st = sub.add_parser("seats", help="등급별 잔여석 조회 (전 일정)")
p_st.add_argument("url", help="공연 URL 또는 platform:id")
p_st.add_argument("--all-dates", action="store_true",
help="YES24 — 6개월 전체 (기본: 3주)")
_common(p_st)
p_st.set_defaults(func=cmd_seats)
p_h = sub.add_parser("health", help="API endpoint reachability check")
_common(p_h)
p_h.set_defaults(func=cmd_health)
args = parser.parse_args(argv)
try:
return args.func(args)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
except MissingHttpxError as e:
print(f"dependency error: {e}", file=sys.stderr)
return 4
except HTTPX_HTTP_ERROR as e:
print(f"http error: {e}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())