mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge remote-tracking branch 'origin/dev' into feature/#303
# Conflicts: # package.json
This commit is contained in:
commit
500647980d
64 changed files with 158 additions and 2240 deletions
5
.changeset/archive-unsupported-map-skills.md
Normal file
5
.changeset/archive-unsupported-map-skills.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
Archive unsupported Naver Map and Blue Ribbon proxy support. The proxy no longer registers `/v1/naver-map/*` or `/v1/blue-ribbon/nearby`, and the unsupported skill/package code is preserved under `legacy/` for a future revival if operational blockers are resolved.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"daiso-product-search": minor
|
||||
---
|
||||
|
||||
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"emergency-room-beds": minor
|
||||
---
|
||||
|
||||
Add an E-Gen based nearby emergency-room status skill and package.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add Kakao Map proxy routes (keyword search, category search, coord2address, coord2region, Kakao Mobility car directions) used by the new kakao-map skill (issue #267). All routes inject server-side KAKAO_REST_API_KEY and never forward caller-supplied apiKey query params.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add NAVER Cloud Platform Maps directions, geocoding, and reverse-geocoding proxy routes used by the new naver-map-route skill (issue #268). Routes inject server-side NAVER_MAP_CLIENT_ID/SECRET and return 503 when the upstream key is missing.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"local-election-candidate-search": minor
|
||||
---
|
||||
|
||||
Add a public NEC local election candidate lookup skill and helper CLI.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add Seoul Bike realtime, station master, and nearby lookup proxy routes.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"sh-notice-search": minor
|
||||
---
|
||||
|
||||
Add a policy-compliant SH public notice search skill and direct HTML lookup client.
|
||||
|
|
@ -77,7 +77,6 @@
|
|||
"./mfds-food-safety",
|
||||
"./myrealtrip-search",
|
||||
"./naver-blog-research",
|
||||
"./naver-map-route",
|
||||
"./naver-news-search",
|
||||
"./naver-shopping-search",
|
||||
"./nts-business-registration",
|
||||
|
|
@ -93,7 +92,6 @@
|
|||
"./seoul-subway-arrival",
|
||||
"./sh-notice-search",
|
||||
"./srt-booking",
|
||||
"./startup-support",
|
||||
"./subway-lost-property",
|
||||
"./ticket-availability",
|
||||
"./toss-securities",
|
||||
|
|
|
|||
8
.github/workflows/deploy-k-skill-proxy.yml
vendored
8
.github/workflows/deploy-k-skill-proxy.yml
vendored
|
|
@ -79,7 +79,6 @@ jobs:
|
|||
SEOUL_OPEN_API_KEY=SEOUL_OPEN_API_KEY:latest
|
||||
HRFCO_OPEN_API_KEY=HRFCO_OPEN_API_KEY:latest
|
||||
OPINET_API_KEY=OPINET_API_KEY:latest
|
||||
BLUE_RIBBON_SESSION_ID=BLUE_RIBBON_SESSION_ID:latest
|
||||
DATA_GO_KR_API_KEY=DATA_GO_KR_API_KEY:latest
|
||||
KEDU_INFO_KEY=KEDU_INFO_KEY:latest
|
||||
DATA4LIBRARY_AUTH_KEY=DATA4LIBRARY_AUTH_KEY:latest
|
||||
|
|
@ -126,15 +125,10 @@ jobs:
|
|||
if not data.get('ok'):
|
||||
print('Health response is not ok:', data)
|
||||
sys.exit(1)
|
||||
# naverMapConfigured is expected to be false until NCP Maps keys are provisioned
|
||||
KNOWN_UNCONFIGURED = {'naverMapConfigured'}
|
||||
missing = [k for k, v in data.get('upstreams', {}).items() if k.endswith('Configured') and v is not True and k not in KNOWN_UNCONFIGURED]
|
||||
missing = [k for k, v in data.get('upstreams', {}).items() if k.endswith('Configured') and v is not True]
|
||||
if missing:
|
||||
print('Upstreams not configured:', missing)
|
||||
sys.exit(1)
|
||||
skipped = [k for k in KNOWN_UNCONFIGURED if not data.get('upstreams', {}).get(k)]
|
||||
if skipped:
|
||||
print(f'Note: {skipped} not yet configured (expected, see docs/features/naver-map-route.md)')
|
||||
print('Health OK. All upstreams configured.')
|
||||
"
|
||||
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,5 +9,6 @@ node_modules/
|
|||
__pycache__/
|
||||
dist/
|
||||
.sisyphus/
|
||||
.omo/
|
||||
|
||||
.agents/
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -33,7 +33,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
|
||||
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
|
||||
| 카카오맵 장소·자동차 길찾기 | `kakao-map` | Kakao Local 키워드/카테고리/좌표↔주소 변환 + Kakao Mobility 자동차 길찾기(거리·소요시간·통행료·예상 택시요금) | 불필요 | [카카오맵 가이드](docs/features/kakao-map.md) |
|
||||
| 네이버맵 자동차 길찾기 | `naver-map-route` | `/route`·`/이동루트` 수동 입력 기반 NCP Maps Directions 5 자동차 경로, 지오코딩, 역지오코딩 조회 (mock 기본, live opt-in) | 불필요 | [네이버맵 길찾기 가이드](docs/features/naver-map-route.md) |
|
||||
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
|
||||
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
|
||||
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
|
||||
|
|
@ -82,10 +81,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 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) |
|
||||
| HWP 레이아웃·IR 디버깅 | `rhwp-advanced` | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
|
||||
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
||||
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
|
||||
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
|
||||
| 창업 지원사업 조회 | `startup-support` | 정부·지자체 스타트업 지원사업 목록 조회 (상세 조회는 원본 공고 링크로 확인) | 불필요 | [창업 지원사업 조회 가이드](docs/features/startup-support.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) |
|
||||
|
|
@ -113,14 +110,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
|
||||
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
|
||||
|
||||
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
|
||||
>
|
||||
> **블루리본 측이 `www.bluer.co.kr` 에 자동화 접근 전면 차단을 적용해 스킬이 더 이상 동작하지 않습니다.**
|
||||
>
|
||||
> - 브라우저·`curl`·Playwright·TLS impersonation 등 가능한 우회를 모두 검증했지만 nginx 단에서 403이 반환되며, 같은 가구 공인 IP로도 특정 장비만 차단되는 상황이 관측되었습니다.
|
||||
> - 유료 회원권 보유자도 접근이 막히는 사례가 확인되었습니다. 복구 여부와 일정은 블루리본 측 정책에 전적으로 달려 있어 이 레포에서 대응할 수 있는 범위를 벗어났습니다.
|
||||
> - 해당 스킬 디렉토리(`blue-ribbon-nearby/`)와 관련 프록시 라우트는 히스토리 보존을 위해 당분간 남겨두지만, **새 프로젝트에서는 해당 스킬을 사용하지 마세요.** 차단이 해제되는 날이 오면 이 안내를 제거하고 재검증하겠습니다.
|
||||
|
||||
## Claude Code 플러그인으로 설치
|
||||
|
||||
[Claude Code](https://claude.com/claude-code)에서는 마켓플레이스로 전체 스킬을 한 번에 설치할 수 있습니다.
|
||||
|
|
@ -166,7 +155,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
|
||||
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
|
||||
- [카카오맵 가이드](docs/features/kakao-map.md)
|
||||
- [네이버맵 길찾기 가이드](docs/features/naver-map-route.md)
|
||||
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
|
||||
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
|
||||
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
|
||||
|
|
@ -214,7 +202,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [HWP 문서 조회/변환](docs/features/hwp.md)
|
||||
- [HWP 문서 편집](docs/features/rhwp-edit.md)
|
||||
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
|
||||
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
|
||||
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
|
||||
- [우편번호 검색](docs/features/zipcode-search.md)
|
||||
- [다이소 상품 조회](docs/features/daiso-product-search.md)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
| 유형 | 설명 | 예시 |
|
||||
|------|------|------|
|
||||
| **SKILL.md 전용** | 문서만으로 동작 (에이전트가 bash/python 직접 실행) | `kakaotalk-mac`, `srt-booking` |
|
||||
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `blue-ribbon-nearby` |
|
||||
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `daiso-product-search` |
|
||||
| **프록시 경유** | `k-skill-proxy`가 upstream API 키를 보관하고 HTTP로 중계 | `seoul-subway-arrival`, `fine-dust-location` |
|
||||
| **Python 스크립트** | `scripts/`의 Python 파일 직접 실행 | `korean-spell-check`, `sillok-search` |
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ gcloud projects add-iam-policy-binding "$PROJECT_ID" \
|
|||
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
|
||||
for s in \
|
||||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
|
||||
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_KEY \
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET; do
|
||||
gcloud secrets add-iam-policy-binding "$s" \
|
||||
|
|
@ -157,7 +157,7 @@ gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \
|
|||
```bash
|
||||
KEYS=(
|
||||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
|
||||
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_KEY
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ python3 scripts/k_skill_cleaner.py \
|
|||
--skills-root . \
|
||||
--scan-default-logs \
|
||||
--days 90 \
|
||||
--never-use blue-ribbon-nearby,lotto-results \
|
||||
--never-use lotto-results \
|
||||
--keep k-skill-setup,k-skill-cleaner
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,221 +0,0 @@
|
|||
# startup-support
|
||||
|
||||
## 스킬 개요
|
||||
|
||||
`startup-support` 스킬은 정부, 지자체, 공기업이 제공하는 스타트업, 중소기업, 개인 창업가를 위한 지원사업 정보를 공식 API를 통해 조회하는 기능을 제공합니다.
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
- "스타트업 지원사업 알려줘"
|
||||
- "중소기업 보조금 종류 정리해줘"
|
||||
- "서울시 창업 지원금 프로그램"
|
||||
- "청년 창업 지원금 요건"
|
||||
- "MVP 지원사업 목록"
|
||||
- "정부 지원사업 마감일"
|
||||
- "스타트업 융자 프로그램"
|
||||
|
||||
## 기능 특징
|
||||
|
||||
### 1. 다양한 데이터 소스
|
||||
- **공공데이터포털 (data.go.kr)**: 중소벤처기업부 스타트업 지원사업 API
|
||||
- **지자체별 공식 사이트**: 서울시, 경기도, 부산시, 광주시, 대구시 등
|
||||
- **공기업 및 기금 관리기관**: 중소기업진흥공단, 기술보증기금 등
|
||||
|
||||
### 2. 전수 검색
|
||||
- 모든 소스를 병렬로 검색하여 누락되는 지원사업이 없도록 함
|
||||
- 실시간 정보 제공 (공고 마감일, 지원금액, 자격 요건)
|
||||
|
||||
### 3. 정확한 정보 제공
|
||||
- 공식 출처의 정보만 사용
|
||||
- 마감 여부는 KST 기준 현재 날짜와 비교하여 판정
|
||||
- 공식 사이트 링크 항상 제공
|
||||
|
||||
## 구현 방식
|
||||
|
||||
### 데이터 흐름
|
||||
|
||||
1. **API 요청**: 사용자의 요청을 받아 적절한 API 호출
|
||||
2. **데이터 수집**: 공공데이터포털, 지자체 API 등에서 병렬 데이터 수집
|
||||
3. **데이터 처리**: 중복 제거, 정렬, 필터링
|
||||
4. **정보 제공**: 사용자에게 정제된 정보 제공
|
||||
|
||||
### API 엔드포인트
|
||||
|
||||
#### k-skill-proxy 라우트
|
||||
|
||||
공공데이터포털 K-Startup OpenAPI는 별도 `kstartup-search` 스킬과 `k-skill-proxy`의 `/v1/kstartup/*` 라우트가 담당합니다. `startup-support` helper는 지역별 공개 API 목록을 조회하고, 상세 정보는 결과의 공식 `url` 로 확인합니다.
|
||||
|
||||
#### Python 스크립트
|
||||
|
||||
```python
|
||||
# 기본 검색
|
||||
programs = search_startup_support()
|
||||
|
||||
# 지역별 검색
|
||||
seoul_programs = search_startup_support(region='서울특별시')
|
||||
|
||||
# 키워드 검색
|
||||
keyword_programs = search_startup_support(keyword='청년')
|
||||
|
||||
# 마감 임박 검색
|
||||
deadline_programs = search_startup_support(deadline_only=True)
|
||||
|
||||
# 상세 정보는 목록 결과의 공식 url로 확인
|
||||
```
|
||||
|
||||
## 데이터 소스
|
||||
|
||||
### 1. 공공데이터포털
|
||||
- **기관**: 중소벤처기업부
|
||||
- **API**: 스타트업 지원사업 정보
|
||||
- **인증**: hosted/self-host proxy 운영 서버에서 API 키 주입
|
||||
|
||||
### 2. 지자체별 사이트
|
||||
- **서울시**: https://seoulstartup.go.kr
|
||||
- **경기도**: https://g-startup.kr
|
||||
- **부산시**: https://busanstartup.kr
|
||||
- **광주시**: https://startup.gwangju.kr
|
||||
- **대구시**: https://daegu-startup.kr
|
||||
|
||||
### 3. 공기업 및 기금
|
||||
- **중소기업진흥공단**: https://smbs.or.kr
|
||||
- **기술보증기금**: https://koreatech.or.kr
|
||||
- **KOTRA**: https://www.kotra.or.kr
|
||||
|
||||
## 출력 형식
|
||||
|
||||
### 지원사업 목록
|
||||
|
||||
```json
|
||||
{
|
||||
"programs": [
|
||||
{
|
||||
"id": "seoul_2024_startup_001",
|
||||
"title": "서울시 청년 스타트업 창업 지원금",
|
||||
"organization": "서울시",
|
||||
"region": "서울특별시",
|
||||
"support_type": "보조금",
|
||||
"amount": "최대 5천만원",
|
||||
"deadline": "2024-12-31",
|
||||
"target": "만 19~34세 청년 창업가",
|
||||
"contact": "02-1234-5678",
|
||||
"url": "https://seoulstartup.go.kr/program/001",
|
||||
"source": "서울시 창업플러스",
|
||||
"last_updated": "2024-05-20"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 상세 정보
|
||||
|
||||
```json
|
||||
{
|
||||
"program": {
|
||||
"id": "seoul_2024_startup_001",
|
||||
"title": "서울시 청년 스타트업 창업 지원금",
|
||||
"organization": "서울시",
|
||||
"region": "서울특별시",
|
||||
"support_type": "보조금",
|
||||
"amount": "최대 5천만원",
|
||||
"deadline": "2024-12-31",
|
||||
"target": "만 19~34세 청년 창업가",
|
||||
"requirements": [
|
||||
"사업자등록증 (개인/법인)",
|
||||
"사업계획서",
|
||||
"재무제표",
|
||||
"창업자 신분증"
|
||||
],
|
||||
"application_process": [
|
||||
"온라인 신청서 작성",
|
||||
"서류 제출",
|
||||
"서류 심사",
|
||||
"현장 면접 (일부)",
|
||||
"결공고"
|
||||
],
|
||||
"contact": {
|
||||
"phone": "02-1234-5678",
|
||||
"email": "startup@seoul.go.kr",
|
||||
"address": "서울시 강남구 테헤란로 123"
|
||||
},
|
||||
"url": "https://seoulstartup.go.kr/program/001",
|
||||
"source": "서울시 창업플러스",
|
||||
"last_updated": "2024-05-20"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 테스트
|
||||
|
||||
### 테스트 실행
|
||||
|
||||
```bash
|
||||
cd /startup-support/scripts
|
||||
python3 test_startup_support.py
|
||||
```
|
||||
|
||||
### 테스트 범위
|
||||
|
||||
1. **기본 기능 테스트**
|
||||
- 서울시 지원사업 조회
|
||||
- 경기도 지원사업 조회
|
||||
- 전국 지원사업 조회
|
||||
|
||||
2. **검색 기능 테스트**
|
||||
- 키워드 검색 ("청년", "MVP", "해외")
|
||||
- 지역별 검색
|
||||
- 마감일 순 정렬
|
||||
|
||||
3. **에러 처리 테스트**
|
||||
- API 연결 실패 시 처리
|
||||
- 데이터 없을 때 처리
|
||||
- 잘못된 파라미터 처리
|
||||
|
||||
## 배포
|
||||
|
||||
### 1. 환경 변수 설정
|
||||
|
||||
```bash
|
||||
export DATA_GO_KR_API_KEY="your_api_key_here"
|
||||
```
|
||||
|
||||
### 2. k-skill-proxy 빌드
|
||||
|
||||
```bash
|
||||
cd packages/k-skill-proxy
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 3. 테스트
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### 4. 배포
|
||||
|
||||
```bash
|
||||
npm run ci
|
||||
```
|
||||
|
||||
## 기여
|
||||
|
||||
### 문제 해결
|
||||
|
||||
1. **API 연결 실패**: 지자체 API 엔드포인트 확인
|
||||
2. **데이터 누락**: 공공데이터포털 API 키 확인
|
||||
3. **성능 문제**: 캐시 시간 조정
|
||||
4. **정확도 문제**: 출처별 데이터 검증
|
||||
|
||||
### 개선 방향
|
||||
|
||||
1. **추가 지원사업**: 다른 지자체 API 추가
|
||||
2. **실시간 업데이트**: 자동 데이터 수집 시스템
|
||||
3. **사용자 경험**: 검색 결과 개선
|
||||
4. **모니터링**: 서비스 상태 모니터링
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [API 문서](references/api-documentation.md)
|
||||
- [데이터 출처](references/data-sources.md)
|
||||
- [지원사업 분류](references/program-categories.md)
|
||||
|
|
@ -86,7 +86,6 @@ npx --yes skills add <owner/repo> \
|
|||
--skill olive-young-search \
|
||||
--skill korean-cinema-search \
|
||||
--skill hola-poke-yeoksam \
|
||||
--skill blue-ribbon-nearby \
|
||||
--skill kakao-bar-nearby \
|
||||
--skill zipcode-search \
|
||||
--skill delivery-tracking \
|
||||
|
|
@ -420,27 +419,3 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
|
|||
|
||||
- [공통 설정 가이드](setup.md)
|
||||
- [보안/시크릿 정책](security-and-secrets.md)
|
||||
|
||||
|
||||
|
||||
### startup-support
|
||||
|
||||
startup-support 스킬은 다음과 같은 환경이 필요합니다:
|
||||
|
||||
#### 환경 변수
|
||||
기본 사용자는 별도 API 키가 필요 없습니다. `DATA_GO_KR_API_KEY` 는 hosted/self-host `k-skill-proxy` 운영자가 서버에 설정하는 값입니다.
|
||||
|
||||
#### 의존성
|
||||
- Python 3.7+
|
||||
- requests 라이브러리
|
||||
- datetime 라이브러리
|
||||
|
||||
#### 설치
|
||||
```bash
|
||||
# 스킬 설치
|
||||
cd startup-support
|
||||
pip install requests
|
||||
|
||||
# 테스트 실행
|
||||
python3 scripts/test_startup_support.py
|
||||
```
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@
|
|||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 근처 공중화장실 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -83,17 +83,3 @@ KSKILL_PROXY_BASE_URL=
|
|||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy`의 `/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy`의 `/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy`의 `/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY` 는 `--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
||||
|
||||
|
||||
### startup-support
|
||||
|
||||
#### API 키 관리
|
||||
- 기본 사용자는 `DATA_GO_KR_API_KEY` 를 설정하지 않습니다.
|
||||
- `DATA_GO_KR_API_KEY` 는 hosted/self-host `k-skill-proxy` 운영자가 서버에 설정하는 upstream 키입니다.
|
||||
- 로컬에서 직접 공공데이터포털 API를 호출하는 실험 경로에서만 사용자 환경에 임시로 둘 수 있습니다.
|
||||
|
||||
#### 데이터 보안
|
||||
- **데이터 소스**: 공공기관 공식 API만 사용
|
||||
- **개인정보**: 개인정보는 처리하지 않음
|
||||
- **접근 제어**: API 키를 통한 접근 제어
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy`의 `/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct`는 `/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
|
||||
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy`의 `/v1/kakao-local/geocode`가 `/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다. 같은 host의 `/search/keyword.json`, `/search/category.json`, `/geo/coord2address.json`, `/geo/coord2regioncode.json` 은 `kakao-map` 스킬용 `/v1/kakao-map/*` 라우트가 직접 중계한다.
|
||||
- Kakao Mobility Directions endpoint: https://apis-navi.kakaomobility.com/v1/directions — `k-skill-proxy`의 `/v1/kakao-mobility/directions`가 운영자 `KAKAO_REST_API_KEY`를 `Authorization: KakaoAK ...` 헤더로 주입해 자동차 길찾기를 중계한다.
|
||||
- NAVER Cloud Platform Maps Console: https://www.ncloud.com/product/applicationService/maps — `k-skill-proxy`의 `/v1/naver-map/directions`, `/v1/naver-map/geocode`, `/v1/naver-map/reverse-geocode`가 각각 `https://maps.apigw.ntruss.com/map-direction/v1/driving`, `/map-geocode/v2/geocode`, `/map-reversegeocode/v2/gc` 로 중계한다. 운영자 `NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET` 가 필요하다.
|
||||
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
|
||||
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
|
||||
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do
|
||||
|
|
@ -156,9 +155,6 @@
|
|||
- 당근알바 검색 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
|
||||
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
|
||||
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
|
||||
- 조선왕조실록 메인: https://sillok.history.go.kr
|
||||
|
|
@ -210,19 +206,6 @@
|
|||
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
|
||||
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
|
||||
|
||||
## startup-support
|
||||
|
||||
### 공공데이터포털 (data.go.kr)
|
||||
- **기관**: 창업진흥원 (K-Startup)
|
||||
- **서비스명**: K-Startup 조회서비스
|
||||
- **데이터셋 페이지**: https://www.data.go.kr/data/15125364/openapi.do
|
||||
- **Open API base URL**: https://apis.data.go.kr/B552735/kisedKstartupService01
|
||||
- **인증**: API 키 필수 (`DATA_GO_KR_API_KEY`, proxy 서버 측 주입)
|
||||
- **프록시 매핑**: `k-skill-proxy`의 `/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics`가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01`으로 중계 (`returnType=json` 고정)
|
||||
|
||||
### 공식 포털 및 상세 공고 진입점
|
||||
- **K-Startup 공식 포털**: https://www.k-startup.go.kr
|
||||
- API 응답의 `detl_pg_url` 필드가 사용자 상세 공고 진입점으로 사용됨
|
||||
|
||||
### 지자체/유관기관 참고 사이트 (보조 소스)
|
||||
- **서울시 창업플러스**: https://seoulstartup.go.kr
|
||||
|
|
|
|||
12
legacy/README.md
Normal file
12
legacy/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Legacy Unsupported Code
|
||||
|
||||
This directory preserves unsupported skills and helper code that are not part of the default k-skill install, plugin manifest, Manus bundles, npm workspaces, proxy route surface, or README feature list.
|
||||
|
||||
Archived items:
|
||||
|
||||
- `unsupported-skills/blue-ribbon-nearby/` - Blue Ribbon nearby skill. The upstream blocks automation/premium access in ways this repository cannot currently support.
|
||||
- `unsupported-skills/naver-map-route/` - Naver Map route skill. NCP Maps operational prerequisites are not currently available for the hosted proxy.
|
||||
- `unsupported-packages/blue-ribbon-nearby/` - Former npm workspace package retained for future revival.
|
||||
- `unsupported-proxy/bluer.js` and `unsupported-proxy/naver-map.js` - Former proxy helper modules retained for future revival.
|
||||
|
||||
To revive one of these surfaces, move the code back into the normal repo layout, restore docs/tests/proxy routes or workspace metadata, and rerun `npm run ci` plus live/manual QA.
|
||||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -560,10 +560,6 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/blue-ribbon-nearby": {
|
||||
"resolved": "packages/blue-ribbon-nearby",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"dev": true,
|
||||
|
|
@ -1744,16 +1740,6 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"packages/blue-ribbon-nearby": {
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"rebrowser-playwright": ">=1.0.0"
|
||||
}
|
||||
},
|
||||
"packages/cheap-gas-nearby": {
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
|
|
@ -1762,7 +1748,7 @@
|
|||
}
|
||||
},
|
||||
"packages/court-auction-notice-search": {
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"court-auction-notice-search": "bin/court-auction-notice-search.js"
|
||||
|
|
@ -1776,7 +1762,7 @@
|
|||
}
|
||||
},
|
||||
"packages/daishin-report-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"daishin-report-search": "src/cli.js"
|
||||
|
|
@ -1786,28 +1772,28 @@
|
|||
}
|
||||
},
|
||||
"packages/daiso-product-search": {
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/donation-place-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/emergency-room-beds": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/gangnamunni-clinic-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"gangnamunni-clinic-search": "src/cli.js"
|
||||
|
|
@ -1817,7 +1803,7 @@
|
|||
}
|
||||
},
|
||||
"packages/gongsijiga-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1844,7 +1830,7 @@
|
|||
}
|
||||
},
|
||||
"packages/k-skill-proxy": {
|
||||
"version": "0.2.0",
|
||||
"version": "0.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify": "^5.3.3"
|
||||
|
|
@ -1888,7 +1874,7 @@
|
|||
}
|
||||
},
|
||||
"packages/korean-marathon-schedule": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"korean-marathon-schedule": "src/cli.js"
|
||||
|
|
@ -1905,7 +1891,7 @@
|
|||
}
|
||||
},
|
||||
"packages/local-election-candidate-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"local-election-candidate-search": "src/cli.js"
|
||||
|
|
@ -1936,7 +1922,7 @@
|
|||
}
|
||||
},
|
||||
"packages/sh-notice-search": {
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sh-notice-search": "src/cli.js"
|
||||
|
|
@ -1946,7 +1932,7 @@
|
|||
}
|
||||
},
|
||||
"packages/toss-securities": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@
|
|||
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.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/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.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/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.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 seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.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 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats 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_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && 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' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts python3 -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.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 && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats 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_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.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 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 && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
"release:npm": "changeset publish"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# daiso-product-search
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7c2dc59: Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "daiso-product-search",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"description": "Official Daiso Mall store/product search and pickup-stock client",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# emergency-room-beds
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4e2d1fa: Add an E-Gen based nearby emergency-room status skill and package.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "emergency-room-beds",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Public E-Gen nearby emergency room status lookup for Korean location queries",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
# k-skill-proxy
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 6d49a28: Add Kakao Map proxy routes (keyword search, category search, coord2address, coord2region, Kakao Mobility car directions) used by the new kakao-map skill (issue #267). All routes inject server-side KAKAO_REST_API_KEY and never forward caller-supplied apiKey query params.
|
||||
- ff2aa91: Add NAVER Cloud Platform Maps directions, geocoding, and reverse-geocoding proxy routes used by the new naver-map-route skill (issue #268). Routes inject server-side NAVER_MAP_CLIENT_ID/SECRET and return 503 when the upstream key is missing.
|
||||
- 540e80b: Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원\_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).
|
||||
- e6d7072: Add Seoul Bike realtime, station master, and nearby lookup proxy routes.
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -30,9 +30,6 @@
|
|||
- `GET /v1/kakao-map/coord2address` — Kakao Local 좌표→도로명/지번 주소(`KAKAO_REST_API_KEY`)
|
||||
- `GET /v1/kakao-map/coord2region` — Kakao Local 좌표→행정구역(`KAKAO_REST_API_KEY`)
|
||||
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
|
||||
- `GET /v1/naver-map/directions` — NCP Maps Directions 5 자동차 길찾기(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/naver-map/geocode` — NCP Maps 주소→좌표 지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/naver-map/reverse-geocode` — NCP Maps 좌표→주소 역지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
|
||||
|
|
@ -73,7 +70,6 @@
|
|||
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
|
||||
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
|
||||
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date`는 `meta.sort_applied: "unsupported"`로 표시
|
||||
- `NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET` — NAVER Cloud Platform Maps subaccount 키. `naver-map/*` 라우트의 **운영 가능 여부**를 결정한다. 키가 없으면 라우트는 `503 upstream_not_configured` 를 돌려준다.
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "k-skill-proxy",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Fastify proxy for k-skill upstream APIs",
|
||||
"license": "MIT",
|
||||
|
|
@ -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/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-map.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",
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.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": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const crypto = require("node:crypto");
|
||||
const Fastify = require("fastify");
|
||||
const { fetchFineDustReport } = require("./airkorea");
|
||||
const { proxyBlueRibbonNearbyRequest } = require("./bluer");
|
||||
const { fetchWaterLevelReport } = require("./hrfco");
|
||||
const { KRX_MARKETS, fetchBaseInfo, fetchTradeInfo, getCurrentKstDate, searchStocks } = require("./krx-stock");
|
||||
const {
|
||||
|
|
@ -28,14 +27,6 @@ const {
|
|||
normalizeKakaoKeywordSearchQuery,
|
||||
normalizeKakaoMobilityDirectionsQuery
|
||||
} = require("./kakao-map");
|
||||
const {
|
||||
fetchNaverMapDirections,
|
||||
fetchNaverMapGeocode,
|
||||
fetchNaverMapReverseGeocode,
|
||||
normalizeNaverMapDirectionsQuery,
|
||||
normalizeNaverMapGeocodeQuery,
|
||||
normalizeNaverMapReverseGeocodeQuery
|
||||
} = require("./naver-map");
|
||||
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
|
||||
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
|
||||
const {
|
||||
|
|
@ -184,7 +175,6 @@ function buildConfig(env = process.env) {
|
|||
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
|
||||
hrfcoApiKey: trimOrNull(env.HRFCO_OPEN_API_KEY),
|
||||
opinetApiKey: trimOrNull(env.OPINET_API_KEY),
|
||||
blueRibbonSessionId: trimOrNull(env.BLUE_RIBBON_SESSION_ID),
|
||||
molitApiKey: trimOrNull(env.DATA_GO_KR_API_KEY),
|
||||
data4libraryAuthKey: trimOrNull(env.DATA4LIBRARY_AUTH_KEY),
|
||||
foodsafetyKoreaApiKey: trimOrNull(env.FOODSAFETYKOREA_API_KEY),
|
||||
|
|
@ -194,8 +184,6 @@ function buildConfig(env = process.env) {
|
|||
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
|
||||
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
|
||||
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
|
||||
naverMapClientId: trimOrNull(env.NAVER_MAP_CLIENT_ID),
|
||||
naverMapClientSecret: trimOrNull(env.NAVER_MAP_CLIENT_SECRET),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -1041,27 +1029,6 @@ function normalizeNeisSchoolSearchQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeBlueRibbonNearbyQuery(query) {
|
||||
const latitude = parseFloatValue(query.latitude ?? query.lat);
|
||||
const longitude = parseFloatValue(query.longitude ?? query.lng);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("Provide latitude and longitude.");
|
||||
}
|
||||
|
||||
const distanceMeters = parseInteger(query.distanceMeters ?? query.distance, 1000);
|
||||
if (distanceMeters <= 0 || distanceMeters > 5000) {
|
||||
throw new Error("distanceMeters must be between 1 and 5000.");
|
||||
}
|
||||
|
||||
const limit = parseInteger(query.limit, 10);
|
||||
if (limit <= 0 || limit > 50) {
|
||||
throw new Error("limit must be between 1 and 50.");
|
||||
}
|
||||
|
||||
return { latitude, longitude, distanceMeters, limit };
|
||||
}
|
||||
|
||||
function normalizeRealEstateQuery(query) {
|
||||
const lawdCd = trimOrNull(query.lawd_cd ?? query.lawdCd);
|
||||
if (!lawdCd || !/^\d{5}$/.test(lawdCd)) {
|
||||
|
|
@ -1897,7 +1864,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
|
||||
app.get("/health", async () => {
|
||||
const naverSearchKeysPresent = Boolean(config.naverSearchClientId && config.naverSearchClientSecret);
|
||||
const naverMapKeysPresent = Boolean(config.naverMapClientId && config.naverMapClientSecret);
|
||||
return {
|
||||
ok: true,
|
||||
service: config.proxyName,
|
||||
|
|
@ -1905,7 +1871,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
upstreams: {
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey),
|
||||
kmaOpenApiConfigured: Boolean(config.kmaOpenApiKey),
|
||||
blueRibbonConfigured: Boolean(config.blueRibbonSessionId),
|
||||
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey),
|
||||
hrfcoConfigured: Boolean(config.hrfcoApiKey),
|
||||
opinetConfigured: Boolean(config.opinetApiKey),
|
||||
|
|
@ -1922,7 +1887,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
naverShoppingConfigured: true,
|
||||
naverSearchApiConfigured: naverSearchKeysPresent,
|
||||
naverNewsApiConfigured: naverSearchKeysPresent,
|
||||
naverMapConfigured: naverMapKeysPresent,
|
||||
ntsBusinessConfigured: Boolean(config.molitApiKey),
|
||||
kstartupConfigured: Boolean(config.molitApiKey)
|
||||
},
|
||||
|
|
@ -2568,67 +2532,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/blue-ribbon/nearby", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeBlueRibbonNearbyQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.blueRibbonSessionId) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "BLUE_RIBBON_SESSION_ID is not configured on the proxy server."
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: "blue-ribbon-nearby",
|
||||
...normalized
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: {
|
||||
hit: true,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const result = await proxyBlueRibbonNearbyRequest({
|
||||
...normalized,
|
||||
sessionId: config.blueRibbonSessionId
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...result,
|
||||
query: normalized,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: {
|
||||
hit: false,
|
||||
ttl_ms: config.cacheTtlMs
|
||||
},
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/opinet/around", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
|
|
@ -4271,79 +4174,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
}
|
||||
|
||||
async function handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route,
|
||||
normalize,
|
||||
fetcher,
|
||||
cacheKeyExtra = {}
|
||||
}) {
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalize(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
const cacheKey = makeCacheKey({ route, ...normalized, ...cacheKeyExtra });
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: { hit: true, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher({
|
||||
...normalized,
|
||||
clientId: config.naverMapClientId,
|
||||
clientSecret: config.naverMapClientSecret
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
const payload = {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
if (error.upstreamStatusCode) {
|
||||
payload.upstream = {
|
||||
status_code: error.upstreamStatusCode
|
||||
};
|
||||
if (error.upstreamBodySnippet) {
|
||||
payload.upstream.body_snippet = error.upstreamBodySnippet;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...result.body,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
reply.code(result.statusCode);
|
||||
reply.header("content-type", "application/json; charset=utf-8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
app.get("/v1/kakao-map/search/keyword", async (request, reply) => handleKakaoLocalEndpointRoute({
|
||||
request,
|
||||
reply,
|
||||
|
|
@ -4440,31 +4270,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/naver-map/directions", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-directions",
|
||||
normalize: normalizeNaverMapDirectionsQuery,
|
||||
fetcher: fetchNaverMapDirections
|
||||
}));
|
||||
|
||||
app.get("/v1/naver-map/geocode", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-geocode",
|
||||
normalize: normalizeNaverMapGeocodeQuery,
|
||||
fetcher: fetchNaverMapGeocode
|
||||
}));
|
||||
|
||||
app.get("/v1/naver-map/reverse-geocode", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-reverse-geocode",
|
||||
normalize: normalizeNaverMapReverseGeocodeQuery,
|
||||
fetcher: fetchNaverMapReverseGeocode
|
||||
}));
|
||||
|
||||
|
||||
async function handleData4LibraryRoute({
|
||||
request,
|
||||
reply,
|
||||
|
|
@ -5070,7 +4875,6 @@ module.exports = {
|
|||
createMemoryCache,
|
||||
isFailureResponse,
|
||||
makeCacheKey,
|
||||
normalizeBlueRibbonNearbyQuery,
|
||||
normalizeData4LibraryBookDetailQuery,
|
||||
normalizeData4LibraryBookExistsQuery,
|
||||
normalizeData4LibraryBookSearchQuery,
|
||||
|
|
@ -5083,9 +4887,6 @@ module.exports = {
|
|||
normalizeKakaoCategorySearchQuery,
|
||||
normalizeKakaoCoordToAddressQuery,
|
||||
normalizeKakaoMobilityDirectionsQuery,
|
||||
normalizeNaverMapDirectionsQuery,
|
||||
normalizeNaverMapGeocodeQuery,
|
||||
normalizeNaverMapReverseGeocodeQuery,
|
||||
normalizeKmaForecastQuery,
|
||||
normalizeKosisDataQuery,
|
||||
normalizeKosisMetaQuery,
|
||||
|
|
@ -5120,9 +4921,6 @@ module.exports = {
|
|||
proxyKstartupRequest,
|
||||
fetchKakaoLocalEndpoint,
|
||||
fetchKakaoMobilityDirections,
|
||||
fetchNaverMapDirections,
|
||||
fetchNaverMapGeocode,
|
||||
fetchNaverMapReverseGeocode,
|
||||
fetchNaverShoppingSearch,
|
||||
proxyOpinetRequest,
|
||||
proxySeoulBikeRealtimeRequest,
|
||||
|
|
@ -5132,5 +4930,3 @@ module.exports = {
|
|||
resolveLatestKmaForecastBase,
|
||||
startServer
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1131,380 +1131,33 @@ test("Kakao Map health endpoint reflects kakaoMapConfigured and kakaoMobilityCon
|
|||
assert.equal(on.json().upstreams.kakaoMobilityConfigured, true);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint returns 503 when proxy lacks Naver Map keys", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called when keys are missing");
|
||||
};
|
||||
|
||||
const app = buildServer({ env: {} });
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979"
|
||||
});
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
assert.match(response.json().message, /NAVER_MAP_CLIENT_ID/);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint injects server-side Naver keys and caches successful responses", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), headers: options.headers });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
message: "found_route",
|
||||
route: {
|
||||
trafast: [
|
||||
{ summary: { distance: 12345, duration: 600000, tollFare: 1000, taxiFare: 0, fuelPrice: 1500 } }
|
||||
]
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "server-naver-id",
|
||||
NAVER_MAP_CLIENT_SECRET: "server-naver-secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const url = "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979&option=trafast";
|
||||
const first = await app.inject({ method: "GET", url });
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.json().code, 0);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
|
||||
const second = await app.inject({ method: "GET", url });
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
|
||||
assert.equal(calls.length, 1, "second request should be served from proxy cache");
|
||||
const parsed = new URL(calls[0].url);
|
||||
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-direction/v1/driving");
|
||||
assert.equal(parsed.searchParams.get("start"), "126.9706,37.5559");
|
||||
assert.equal(parsed.searchParams.get("goal"), "127.0276,37.4979");
|
||||
assert.equal(parsed.searchParams.get("option"), "trafast");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "server-naver-id");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "server-naver-secret");
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint validates coordinate input shape", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called for invalid input");
|
||||
};
|
||||
|
||||
test("unsupported Naver Map and Blue Ribbon proxy routes are not registered", async (t) => {
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
NAVER_MAP_CLIENT_SECRET: "secret",
|
||||
BLUE_RIBBON_SESSION_ID: "session"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const bad = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=not-coords&goal=127.0,37.5"
|
||||
});
|
||||
assert.equal(bad.statusCode, 400);
|
||||
assert.equal(bad.json().error, "bad_request");
|
||||
|
||||
const missing = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5"
|
||||
});
|
||||
assert.equal(missing.statusCode, 400);
|
||||
assert.equal(missing.json().error, "bad_request");
|
||||
|
||||
const outOfRange = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=999,37.5&goal=127,37.5"
|
||||
});
|
||||
assert.equal(outOfRange.statusCode, 400);
|
||||
|
||||
const badOption = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5&option=fastest"
|
||||
});
|
||||
assert.equal(badOption.statusCode, 400);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint surfaces upstream semantic failures as 502 without caching", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({ code: 5, message: "no_route_found" }),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const url = "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5";
|
||||
const first = await app.inject({ method: "GET", url });
|
||||
assert.equal(first.statusCode, 502);
|
||||
assert.equal(first.json().error, "upstream_semantic_error");
|
||||
|
||||
const second = await app.inject({ method: "GET", url });
|
||||
assert.equal(second.statusCode, 502);
|
||||
assert.equal(calls.length, 2, "semantic failures must not be cached");
|
||||
});
|
||||
|
||||
test("Naver Map endpoints sanitize upstream auth errors as 503 without leaking the body", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let upstreamStatus = 401;
|
||||
global.fetch = async () => new Response(`Authentication Failed: ${upstreamStatus} secret diagnostic`, {
|
||||
status: upstreamStatus,
|
||||
headers: { "content-type": "text/plain" }
|
||||
});
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
upstreamStatus: 401,
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
|
||||
},
|
||||
{
|
||||
upstreamStatus: 403,
|
||||
url: "/v1/naver-map/geocode?q=%EC%84%9C%EC%9A%B8%EC%97%AD"
|
||||
},
|
||||
{
|
||||
upstreamStatus: 401,
|
||||
url: "/v1/naver-map/reverse-geocode?coords=126.9,37.5"
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
upstreamStatus = testCase.upstreamStatus;
|
||||
const response = await app.inject({ method: "GET", url: testCase.url });
|
||||
assert.equal(response.statusCode, 503);
|
||||
const body = response.json();
|
||||
assert.equal(body.error, "upstream_error");
|
||||
assert.equal(body.upstream.status_code, testCase.upstreamStatus);
|
||||
const serialized = JSON.stringify(body);
|
||||
assert.ok(!serialized.includes("Authentication Failed"));
|
||||
assert.ok(!serialized.includes("secret diagnostic"));
|
||||
assert.equal(body.upstream.body_snippet, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("Naver Map endpoints preserve upstream 429 for caller backoff", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => new Response("quota exceeded diagnostic", {
|
||||
status: 429,
|
||||
headers: { "content-type": "text/plain" }
|
||||
});
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const cases = [
|
||||
const urls = [
|
||||
"/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5",
|
||||
"/v1/naver-map/geocode?q=%EC%84%9C%EC%9A%B8%EC%97%AD",
|
||||
"/v1/naver-map/reverse-geocode?coords=126.9,37.5"
|
||||
"/v1/naver-map/reverse-geocode?coords=126.9,37.5",
|
||||
"/v1/blue-ribbon/nearby?lat=37.5&lng=127.0"
|
||||
];
|
||||
|
||||
for (const url of cases) {
|
||||
for (const url of urls) {
|
||||
const response = await app.inject({ method: "GET", url });
|
||||
assert.equal(response.statusCode, 429);
|
||||
const body = response.json();
|
||||
assert.equal(body.error, "upstream_error");
|
||||
assert.equal(body.upstream.status_code, 429);
|
||||
assert.equal(body.upstream.body_snippet, "quota exceeded diagnostic");
|
||||
assert.equal(response.statusCode, 404, `${url} should be archived, not served by the proxy`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint keeps non-auth upstream snippets for diagnostics", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => new Response("Transient upstream diagnostic", {
|
||||
status: 500,
|
||||
headers: { "content-type": "text/plain" }
|
||||
});
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 502);
|
||||
const body = response.json();
|
||||
assert.equal(body.upstream.status_code, 500);
|
||||
assert.equal(body.upstream.body_snippet, "Transient upstream diagnostic");
|
||||
});
|
||||
|
||||
test("Naver Map geocode endpoint injects Naver keys and forwards query, count, and language", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), headers: options.headers });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: "OK",
|
||||
meta: { totalCount: 1, page: 1, count: 1 },
|
||||
addresses: [
|
||||
{
|
||||
roadAddress: "서울특별시 중구 한강대로 405",
|
||||
jibunAddress: "서울특별시 중구 봉래동2가 122",
|
||||
x: "126.9706",
|
||||
y: "37.5559"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "geo-id",
|
||||
NAVER_MAP_CLIENT_SECRET: "geo-secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/geocode?q=" + encodeURIComponent("서울역") + "&count=5"
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().status, "OK");
|
||||
assert.equal(response.json().addresses[0].x, "126.9706");
|
||||
|
||||
const parsed = new URL(calls[0].url);
|
||||
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-geocode/v2/geocode");
|
||||
assert.equal(parsed.searchParams.get("query"), "서울역");
|
||||
assert.equal(parsed.searchParams.get("count"), "5");
|
||||
assert.equal(parsed.searchParams.get("language"), "kor");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "geo-id");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "geo-secret");
|
||||
});
|
||||
|
||||
test("Naver Map reverse-geocode endpoint validates coords and orders", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called");
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const missing = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/reverse-geocode"
|
||||
});
|
||||
assert.equal(missing.statusCode, 400);
|
||||
|
||||
const badOrder = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&orders=banana"
|
||||
});
|
||||
assert.equal(badOrder.statusCode, 400);
|
||||
|
||||
const xmlOutput = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&output=xml"
|
||||
});
|
||||
assert.equal(xmlOutput.statusCode, 400);
|
||||
assert.equal(xmlOutput.json().error, "bad_request");
|
||||
assert.match(xmlOutput.json().message, /output as json/);
|
||||
});
|
||||
|
||||
test("Naver Map health endpoint reflects naverMapConfigured flag", async (t) => {
|
||||
const appOff = buildServer({ env: {} });
|
||||
t.after(async () => {
|
||||
await appOff.close();
|
||||
});
|
||||
const offResponse = await appOff.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(offResponse.json().upstreams.naverMapConfigured, false);
|
||||
|
||||
const appOn = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
t.after(async () => {
|
||||
await appOn.close();
|
||||
});
|
||||
const onResponse = await appOn.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(onResponse.json().upstreams.naverMapConfigured, true);
|
||||
const health = await app.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(Object.hasOwn(health.json().upstreams, "naverMapConfigured"), false);
|
||||
});
|
||||
|
||||
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# local-election-candidate-search
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 68abad3: Add a public NEC local election candidate lookup skill and helper CLI.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "local-election-candidate-search",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Public NEC Korean local election candidate lookup client for k-skill",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# sh-notice-search
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c83e194: Add a policy-compliant SH public notice search skill and direct HTML lookup client.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sh-notice-search",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Public SH Seoul Housing notice lookup client for k-skill",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const EXCLUDED_DIRS = new Set([
|
|||
".idea",
|
||||
"docs",
|
||||
"dist",
|
||||
"legacy",
|
||||
"node_modules",
|
||||
"packages",
|
||||
"python-packages",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const EXCLUDED_DIRS = new Set([
|
|||
".idea",
|
||||
"docs",
|
||||
"dist",
|
||||
"legacy",
|
||||
"node_modules",
|
||||
"packages",
|
||||
"python-packages",
|
||||
|
|
@ -54,7 +55,7 @@ const EXCLUDED_DIRS = new Set([
|
|||
|
||||
// Skills that exist on disk but must not ship in the plugin (e.g. upstream
|
||||
// blocked automation and the skill no longer works).
|
||||
const EXCLUDED_SKILLS = new Set(["blue-ribbon-nearby"]);
|
||||
const EXCLUDED_SKILLS = new Set(["blue-ribbon-nearby", "naver-map-route"]);
|
||||
|
||||
// Identity fields used when the manifest does not exist yet. Existing values
|
||||
// are never overwritten; only missing keys are backfilled.
|
||||
|
|
|
|||
|
|
@ -1621,58 +1621,48 @@ test("kleague-results package README stays aligned with the official K League JS
|
|||
assert.match(packageReadme, /FC서울/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the blue-ribbon-nearby skill across the documented surfaces", () => {
|
||||
test("unsupported naver map and blue ribbon skills are archived outside default docs", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const roadmap = read(path.join("docs", "roadmap.md"));
|
||||
const sources = read(path.join("docs", "sources.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md");
|
||||
const legacyReadmePath = path.join(repoRoot, "legacy", "README.md");
|
||||
const blueRibbonSkillPath = path.join(repoRoot, "legacy", "unsupported-skills", "blue-ribbon-nearby", "SKILL.md");
|
||||
const naverMapSkillPath = path.join(repoRoot, "legacy", "unsupported-skills", "naver-map-route", "SKILL.md");
|
||||
const blueRibbonDocPath = path.join(
|
||||
repoRoot,
|
||||
"legacy",
|
||||
"unsupported-skills",
|
||||
"blue-ribbon-nearby",
|
||||
"docs",
|
||||
"features",
|
||||
"blue-ribbon-nearby.md",
|
||||
);
|
||||
const naverMapDocPath = path.join(
|
||||
repoRoot,
|
||||
"legacy",
|
||||
"unsupported-skills",
|
||||
"naver-map-route",
|
||||
"docs",
|
||||
"features",
|
||||
"naver-map-route.md",
|
||||
);
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/blue-ribbon-nearby.md to exist");
|
||||
assert.match(readme, /\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \|/);
|
||||
assert.match(readme, /\[근처 블루리본 맛집 가이드\]\(docs\/features\/blue-ribbon-nearby\.md\)/);
|
||||
assert.match(readme, /블루리본 측이 `www\.bluer\.co\.kr` 에 자동화 접근 전면 차단/);
|
||||
assert.match(install, /--skill blue-ribbon-nearby/);
|
||||
assert.match(roadmap, /근처 블루리본 맛집 스킬 출시/);
|
||||
assert.match(sources, /블루리본 지역 검색: https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
||||
assert.match(sources, /블루리본 주변 맛집 JSON: https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
||||
});
|
||||
assert.ok(fs.existsSync(legacyReadmePath), "expected legacy/README.md to explain archived unsupported skills");
|
||||
assert.ok(fs.existsSync(blueRibbonSkillPath), "expected blue-ribbon-nearby SKILL.md to be preserved under legacy");
|
||||
assert.ok(fs.existsSync(naverMapSkillPath), "expected naver-map-route SKILL.md to be preserved under legacy");
|
||||
assert.ok(fs.existsSync(blueRibbonDocPath), "expected blue-ribbon-nearby feature doc to be preserved under legacy");
|
||||
assert.ok(fs.existsSync(naverMapDocPath), "expected naver-map-route feature doc to be preserved under legacy");
|
||||
assert.ok(!fs.existsSync(path.join(repoRoot, "blue-ribbon-nearby", "SKILL.md")));
|
||||
assert.ok(!fs.existsSync(path.join(repoRoot, "naver-map-route", "SKILL.md")));
|
||||
assert.ok(!fs.existsSync(path.join(repoRoot, "docs", "features", "blue-ribbon-nearby.md")));
|
||||
assert.ok(!fs.existsSync(path.join(repoRoot, "docs", "features", "naver-map-route.md")));
|
||||
|
||||
test("blue-ribbon-nearby skill documents mandatory location prompting and official Blue Ribbon nearby search flow", () => {
|
||||
const skillPath = path.join(repoRoot, "blue-ribbon-nearby", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected blue-ribbon-nearby/SKILL.md to exist");
|
||||
|
||||
const skill = read(path.join("blue-ribbon-nearby", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "blue-ribbon-nearby.md"));
|
||||
|
||||
assert.match(skill, /^name: blue-ribbon-nearby$/m);
|
||||
assert.match(skill, /^description: .*근처 맛집.*블루리본.*$/m);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /반드시.*현재 위치/u);
|
||||
assert.match(doc, /맛집.*기본적으로.*blue-ribbon-nearby|맛집.*기본적으로.*블루리본/u);
|
||||
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
||||
assert.match(doc, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
||||
assert.match(doc, /zone2Lat/);
|
||||
assert.match(doc, /zone2Lng/);
|
||||
assert.match(doc, /isAround=true/);
|
||||
assert.match(doc, /ribbon=true/);
|
||||
assert.match(doc, /위도|경도|동네|역명/u);
|
||||
assert.match(doc, /blue-ribbon-nearby|근처 블루리본 맛집/u);
|
||||
for (const doc of [readme, install, sources]) {
|
||||
assert.doesNotMatch(doc, /blue-ribbon-nearby|naver-map-route/);
|
||||
assert.doesNotMatch(doc, /근처 블루리본 맛집|네이버맵 길찾기|네이버맵 자동차 길찾기/);
|
||||
}
|
||||
});
|
||||
|
||||
test("blue-ribbon-nearby package README stays aligned with the location-first and official-surface guidance", () => {
|
||||
const packageReadme = read(path.join("packages", "blue-ribbon-nearby", "README.md"));
|
||||
|
||||
assert.match(packageReadme, /먼저 현재 위치를 묻/u);
|
||||
assert.match(packageReadme, /코엑스.*삼성동\/대치동/u);
|
||||
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/search\/zone/);
|
||||
assert.match(packageReadme, /https:\/\/www\.bluer\.co\.kr\/restaurants\/map/);
|
||||
assert.match(packageReadme, /searchNearbyByLocationQuery/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the kakao-bar-nearby skill across the documented surfaces", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
@ -4045,21 +4035,20 @@ test("README skill table includes inline-code skill names for every documented r
|
|||
}
|
||||
});
|
||||
|
||||
test("README skill table strikes through the deprecated blue-ribbon-nearby skill name (issue #165)", () => {
|
||||
const readme = read("README.md");
|
||||
test("legacy blue ribbon package is not part of npm workspaces or pack dry run", () => {
|
||||
const packageJson = readJson("package.json");
|
||||
const packageLock = readJson("package-lock.json");
|
||||
const packScript = packageJson.scripts["pack:dry-run"];
|
||||
|
||||
assert.match(
|
||||
readme,
|
||||
/\| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 \| ~~`blue-ribbon-nearby`~~ \|/,
|
||||
"expected the deprecated blue-ribbon-nearby row to keep the strikethrough on its skill-name cell as well",
|
||||
);
|
||||
assert.doesNotMatch(packScript, /workspace blue-ribbon-nearby(?:\s|$)/);
|
||||
assert.ok(!fs.existsSync(path.join(repoRoot, "packages", "blue-ribbon-nearby", "package.json")));
|
||||
assert.ok(fs.existsSync(path.join(repoRoot, "legacy", "unsupported-packages", "blue-ribbon-nearby", "package.json")));
|
||||
assert.ok(!Object.hasOwn(packageLock.packages, "packages/blue-ribbon-nearby"));
|
||||
assert.ok(!Object.hasOwn(packageLock.packages, "node_modules/blue-ribbon-nearby"));
|
||||
});
|
||||
|
||||
test("README skill table skill-name column entries match real on-disk skill directories (issue #165)", () => {
|
||||
const allEntries = [
|
||||
...README_SKILL_NAME_COLUMN_MAPPING.map(([, skillName]) => skillName),
|
||||
"blue-ribbon-nearby",
|
||||
];
|
||||
const allEntries = README_SKILL_NAME_COLUMN_MAPPING.map(([, skillName]) => skillName);
|
||||
|
||||
for (const skillName of allEntries) {
|
||||
const skillFile = path.join(repoRoot, skillName, "SKILL.md");
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ test("EXCLUDED_DIRS stays in lockstep with validate-skills.sh exclusions", () =>
|
|||
"python-packages",
|
||||
"scripts",
|
||||
"examples",
|
||||
"legacy",
|
||||
];
|
||||
for (const dir of required) {
|
||||
assert.ok(
|
||||
|
|
@ -70,6 +71,15 @@ test("EXCLUDED_DIRS stays in lockstep with validate-skills.sh exclusions", () =>
|
|||
}
|
||||
});
|
||||
|
||||
test("legacy folder is not treated as a Manus skill bundle source", () => {
|
||||
const { EXCLUDED_DIRS, discoverSkills } = require("./build-manus-bundle.js");
|
||||
|
||||
assert.ok(EXCLUDED_DIRS.has("legacy"));
|
||||
assert.ok(!discoverSkills().includes("legacy"));
|
||||
assert.ok(!discoverSkills().includes("blue-ribbon-nearby"));
|
||||
assert.ok(!discoverSkills().includes("naver-map-route"));
|
||||
});
|
||||
|
||||
test("docs/install-manus.md documents both the GitHub URL path and the .skill bundle path", () => {
|
||||
const doc = fs.readFileSync(path.join(repoRoot, "docs", "install-manus.md"), "utf8");
|
||||
assert.match(doc, /tree\/main\//, "must explain per-skill folder URL pattern");
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ test("discoverSkillPaths drops deprecated EXCLUDED_SKILLS", () => {
|
|||
assert.deepEqual(discoverSkillPaths(root), ["./lotto-results"]);
|
||||
});
|
||||
|
||||
test("discoverSkillPaths ignores legacy skills even when they contain SKILL.md", () => {
|
||||
const root = makeFixtureRoot({
|
||||
"legacy/blue-ribbon-nearby/SKILL.md": SKILL_FM,
|
||||
"legacy/naver-map-route/SKILL.md": SKILL_FM,
|
||||
"naver-map-route/SKILL.md": SKILL_FM,
|
||||
"lotto-results/SKILL.md": SKILL_FM,
|
||||
});
|
||||
|
||||
assert.ok(EXCLUDED_SKILLS.has("naver-map-route"));
|
||||
assert.deepEqual(discoverSkillPaths(root), ["./lotto-results"]);
|
||||
});
|
||||
|
||||
test("buildManifest backfills identity fields and preserves author overrides", () => {
|
||||
const root = makeFixtureRoot({ "lotto-results/SKILL.md": SKILL_FM });
|
||||
// Pre-seed a manifest with a custom description that must survive.
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ done < <(
|
|||
! -name .venv \
|
||||
! -name dist \
|
||||
! -name docs \
|
||||
! -name legacy \
|
||||
! -name node_modules \
|
||||
! -name packages \
|
||||
! -name python-packages \
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
---
|
||||
name: startup-support
|
||||
description: Search Korean government startup support programs, grants, and subsidies for startups, SMEs, and entrepreneurs through various public APIs. Use when users ask about 창업 지원, 스타트업 지원금, 중소기업 지원, 정부 지원사업.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: business-support
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# 스타트업 지원사업 조회
|
||||
|
||||
## What this skill does
|
||||
|
||||
정부, 지자체, 공기업이 제공하는 **스타트업, 중소기업, 개인 창업가**를 위한 지원사업, 보조금, 융자 프로그램, 멘토링, 교육 프로그램 등을 공식 API를 통해 조회한다.
|
||||
|
||||
본 스킬은 창업 준비 중이거나, 사업 초기 단계에 있는 스타트업 창업가가 **어떤 지원사업이 있는지 빠르게 파악**하고, 지원 가능한 자격 요건을 확인하며, 공고 마감일을 추적할 수 있도록 돕는다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "스타트업 지원사업 알려줘"
|
||||
- "중소기업 보조금 종류 정리해줘"
|
||||
- "서울시 창업 지원금 프로그램"
|
||||
- "청년 창업 지원금 요건"
|
||||
- "MVP 지원사업 목록"
|
||||
- "정부 지원사업 마감일"
|
||||
- "스타트업 융자 프로그램"
|
||||
|
||||
## When not to use
|
||||
|
||||
- 개별 지원금 신청 자동화 (본 스킬은 read-only 조회다)
|
||||
- 세무/회계 절차 대체 (전문가 상담 필요)
|
||||
- 법적 자격 심사 결정
|
||||
- 실제 지급액 계산 (정확한 금액은 공식 사이트 확인)
|
||||
- 창업아이디어 구체화 컨설팅
|
||||
|
||||
## Core principle
|
||||
|
||||
1. **공식 API 우선**: 정부 공공데이터포털(`data.go.kr`), 각 지자체 API, 공기업 API 등 공식 출처를 사용
|
||||
2. **전수 검색**: 모든 소스를 병렬로 검색하여 누락되는 지원사업이 없도록 함
|
||||
3. **실시간 정보**: 공고 마감일, 지원금액, 자격 요건 등 최신 정보만 제공
|
||||
4. **정확한 한글**: 전문 용어를 정확히 표기 (예: "MVP 지원" vs "MVP지원")
|
||||
|
||||
## Implementation
|
||||
|
||||
### Data Sources
|
||||
|
||||
1. **공공데이터포털 (data.go.kr)**
|
||||
- 중소벤처기업부 스타트업 지원사업 API
|
||||
- 서울시 창업 지원 프로그램 API
|
||||
- 각 지자체 창업 지원사업 API
|
||||
|
||||
2. **지자체별 공식 사이트**
|
||||
- 서울시 창업플러스 (seoulstartup.go.kr)
|
||||
- 경기도 창업진흥원 (g-startup.kr)
|
||||
- 부산시 스타트업 허브 (busanstartup.kr)
|
||||
- 광주창업파크 (startup.gwangju.kr)
|
||||
- 대구창업진흥원 (daegu-startup.kr)
|
||||
|
||||
3. **공기업 및 기금 관리기관**
|
||||
- 중소기업진흥공단 (smbs.or.kr)
|
||||
- 기술보증기금 (koreatech.or.kr)
|
||||
- KOTRA 해외진출 지원
|
||||
- 중소벤처기업금융공단
|
||||
|
||||
### Proxy Integration
|
||||
|
||||
공공데이터포털 K-Startup OpenAPI는 `kstartup-search` 스킬과 `k-skill-proxy`의 `/v1/kstartup/*` 라우트가 담당한다. 이 스킬의 helper는 지역별 공개 API 목록을 조회하고, 상세 정보는 결과의 공식 `url` 로 확인한다.
|
||||
|
||||
## Output format
|
||||
|
||||
### 지원사업 목록
|
||||
|
||||
```json
|
||||
{
|
||||
"programs": [
|
||||
{
|
||||
"id": "seoul_2024_startup_001",
|
||||
"title": "서울시 청년 스타트업 창업 지원금",
|
||||
"organization": "서울시",
|
||||
"region": "서울특별시",
|
||||
"support_type": "보조금",
|
||||
"amount": "최대 5천만원",
|
||||
"deadline": "2024-12-31",
|
||||
"target": "만 19~34세 청년 창업가",
|
||||
"contact": "02-1234-5678",
|
||||
"url": "https://seoulstartup.go.kr/program/001",
|
||||
"source": "서울시 창업플러스",
|
||||
"last_updated": "2024-05-20"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 특정 지원사업 상세 정보
|
||||
|
||||
```json
|
||||
{
|
||||
"program": {
|
||||
"id": "seoul_2024_startup_001",
|
||||
"title": "서울시 청년 스타트업 창업 지원금",
|
||||
"organization": "서울시",
|
||||
"region": "서울특별시",
|
||||
"support_type": "보조금",
|
||||
"amount": "최대 5천만원",
|
||||
"deadline": "2024-12-31",
|
||||
"target": "만 19~34세 청년 창업가",
|
||||
"requirements": [
|
||||
"사업자등록증 (개인/법인)",
|
||||
"사업계획서",
|
||||
"재무제표",
|
||||
"창업자 신분증"
|
||||
],
|
||||
"application_process": [
|
||||
"온라인 신청서 작성",
|
||||
"서류 제출",
|
||||
"서류 심사",
|
||||
"현장 면접 (일부)",
|
||||
"결공고"
|
||||
],
|
||||
"contact": {
|
||||
"phone": "02-1234-5678",
|
||||
"email": "startup@seoul.go.kr",
|
||||
"address": "서울시 강남구 테헤란로 123"
|
||||
},
|
||||
"url": "https://seoulstartup.go.kr/program/001",
|
||||
"source": "서울시 창업플러스",
|
||||
"last_updated": "2024-05-20"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### 테스트 케이스
|
||||
|
||||
1. **기본 기능 테스트**
|
||||
- 서울시 지원사업 조회
|
||||
- 경기도 지원사업 조회
|
||||
- 전국 지원사업 조회
|
||||
|
||||
2. **검색 기능 테스트**
|
||||
- 키워드 검색 ("청년", "MVP", "해외")
|
||||
- 지역별 검색
|
||||
- 마감일 순 정렬
|
||||
|
||||
3. **에러 처리 테스트**
|
||||
- API 연결 실패 시 처리
|
||||
- 데이터 없을 때 처리
|
||||
- 잘못된 파라미터 처리
|
||||
|
||||
### 테스트 데이터
|
||||
|
||||
테스트 시 다음과 같은 가상 데이터를 사용:
|
||||
|
||||
```json
|
||||
{
|
||||
"test_programs": [
|
||||
{
|
||||
"id": "test_001",
|
||||
"title": "테스트 스타트업 지원사업",
|
||||
"organization": "테스트 기관",
|
||||
"region": "테스트 지역",
|
||||
"support_type": "보조금",
|
||||
"amount": "최대 1천만원",
|
||||
"deadline": "2024-12-31",
|
||||
"target": "테스트 대상",
|
||||
"contact": "02-1234-5678",
|
||||
"url": "https://test.example.com",
|
||||
"source": "테스트 소스",
|
||||
"last_updated": "2024-05-20"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `SKILL.md` - 이 문서
|
||||
- `scripts/` - Python 스크립트 구현
|
||||
- `startup_support.py` - 메인 로직
|
||||
- `test_startup_support.py` - 테스트 파일
|
||||
- `references/` - 참고 자료
|
||||
- `api-documentation.md` - API 문서
|
||||
- `data-sources.md` - 데이터 출처
|
||||
- `program-categories.md` - 지원사업 분류
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# 스타트업 지원사업 API 문서
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### k-skill-proxy 라우트
|
||||
|
||||
#### 목록 조회
|
||||
```
|
||||
GET /v1/startup-support/list
|
||||
```
|
||||
|
||||
파라미터:
|
||||
- `region`: 지역 (선택)
|
||||
- `keyword`: 검색 키워드 (선택)
|
||||
- `support_type`: 지원 유형 (선택)
|
||||
- `deadline_only`: 마감 임박만 검색 (선택)
|
||||
|
||||
#### 상세 조회
|
||||
```
|
||||
GET /v1/startup-support/detail/:program_id
|
||||
```
|
||||
|
||||
#### 지역별 조회
|
||||
```
|
||||
GET /v1/startup-support/region/:region
|
||||
```
|
||||
|
||||
#### 마감 임박 조회
|
||||
```
|
||||
GET /v1/startup-support/deadline
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
### 클래스 구조
|
||||
|
||||
```python
|
||||
class StartupSupportAPI:
|
||||
def __init__(self)
|
||||
def search_programs(self, region, keyword, support_type, deadline_only)
|
||||
def get_program_detail(self, program_id)
|
||||
def _search_data_go_kr(self, region, keyword, support_type)
|
||||
def _search_by_region(self, region, keyword, support_type)
|
||||
def _parse_program_from_data_go_kr(self, item)
|
||||
def _parse_program_from_region_api(self, item, region)
|
||||
def _filter_upcoming_deadline(self, programs)
|
||||
def _remove_duplicates(self, programs)
|
||||
def _sort_programs(self, programs)
|
||||
```
|
||||
|
||||
### 사용 예제
|
||||
|
||||
```python
|
||||
# 기본 검색
|
||||
programs = search_startup_support()
|
||||
|
||||
# 지역별 검색
|
||||
seoul_programs = search_startup_support(region='서울특별시')
|
||||
|
||||
# 키워드 검색
|
||||
keyword_programs = search_startup_support(keyword='청년')
|
||||
|
||||
# 마감 임박 검색
|
||||
deadline_programs = search_startup_support(deadline_only=True)
|
||||
|
||||
# 상세 정보 조회
|
||||
detail = get_startup_program_detail('test_001')
|
||||
```
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
# 스타트업 지원사업 데이터 출처
|
||||
|
||||
## 1. 공공데이터포털 (data.go.kr)
|
||||
|
||||
### API 정보
|
||||
- **기관**: 중소벤처기업부
|
||||
- **서비스명**: 스타트업 지원사업 정보
|
||||
- **API URL**: https://www.data.go.kr/api/15058530/openapi
|
||||
- **인증**: API 키 필수 (DATA_GO_KR_API_KEY 환경 변수)
|
||||
|
||||
### 데이터 구조
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"pan_id": "공고ID",
|
||||
"pan_nm": "공고명",
|
||||
"cnp_cd_nm": "지역명",
|
||||
"support_type": "지원 유형",
|
||||
"amount": "지원 금액",
|
||||
"clsg_dt": "마감일",
|
||||
"target": "대상",
|
||||
"contact": "연락처",
|
||||
"detail_url": "상세 URL"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 사용 예제
|
||||
```python
|
||||
response = requests.get(url, params=params, headers=headers)
|
||||
data = response.json()
|
||||
```
|
||||
|
||||
## 2. 지자체별 공식 사이트
|
||||
|
||||
### 서울시 창업플러스
|
||||
- **URL**: https://seoulstartup.go.kr
|
||||
- **API 엔드포인트**: https://seoulstartup.go.kr/api/program/list
|
||||
- **특징**: 서울시 내 스타트업 지원사업 전체
|
||||
|
||||
### 경기도 창업진흥원
|
||||
- **URL**: https://g-startup.kr
|
||||
- **API 엔드포인트**: https://g-startup.kr/api/support/list
|
||||
- **특징**: 경기도 내 스타트업 지원사업
|
||||
|
||||
### 부산시 스타트업 허브
|
||||
- **URL**: https://busanstartup.kr
|
||||
- **API 엔드포인트**: https://busanstartup.kr/api/program/list
|
||||
- **특징**: 부산시 내 스타트업 지원사업
|
||||
|
||||
### 광주창업파크
|
||||
- **URL**: https://startup.gwangju.kr
|
||||
- **API 엔드포인트**: https://startup.gwangju.kr/api/support/list
|
||||
- **특징**: 광주시 내 스타트업 지원사업
|
||||
|
||||
### 대구창업진흥원
|
||||
- **URL**: https://daegu-startup.kr
|
||||
- **API 엔드포인트**: https://daegu-startup.kr/api/program/list
|
||||
- **특징**: 대구시 내 스타트업 지원사업
|
||||
|
||||
## 3. 공기업 및 기금 관리기관
|
||||
|
||||
### 중소기업진흥공단 (SMBS)
|
||||
- **URL**: https://smbs.or.kr
|
||||
- **제공 서비스**: 중소기업 지원금, 융자 프로그램
|
||||
- **API**: 공공데이터포털 통합
|
||||
|
||||
### 기술보증기금
|
||||
- **URL**: https://koreatech.or.kr
|
||||
- **제공 서비스**: 기술 기반 스타트업 보증 지원
|
||||
- **API**: 공공데이터포털 통합
|
||||
|
||||
### KOTRA
|
||||
- **URL**: https://www.kotra.or.kr
|
||||
- **제공 서비스**: 해외 진출 지원사업
|
||||
- **API**: 별도 API 제공
|
||||
|
||||
### 중소벤처기업금융공단
|
||||
- **URL**: https://www.sbc.or.kr
|
||||
- **제공 서비스**: 스타트업 투자, 융자
|
||||
- **API**: 공공데이터포털 통합
|
||||
|
||||
## 4. 데이터 통합 방식
|
||||
|
||||
### 1단계: API 호출
|
||||
- 공공데이터포털 API 호출
|
||||
- 지자체별 API 병렬 호출
|
||||
- 공기업 API 호출 (필요 시)
|
||||
|
||||
### 2단계: 데이터 파싱
|
||||
- 각 API 응답 구조에 맞게 데이터 추출
|
||||
- 필수 필드 검증 (ID, 제목, 지역, 마감일 등)
|
||||
- 데이터 정규화 (지역명, 지원 유형 표준화)
|
||||
|
||||
### 3단계: 중복 제거
|
||||
- ID 기준 중복 제거
|
||||
- 동일 지원사업 합치기
|
||||
- 최신 정보 유지
|
||||
|
||||
### 4단계: 정렬
|
||||
- 마감일 기준 정렬 (가까운 순)
|
||||
- 지역별 그룹화
|
||||
- 지원 유형별 분류
|
||||
|
||||
## 5. 데이터 업데이트 전략
|
||||
|
||||
### 주기적 업데이트
|
||||
- 공공데이터포털: 매일 2회 (09:00, 15:00)
|
||||
- 지자체별: 매일 1회 (09:00)
|
||||
- 공기업: 주간 업데이트
|
||||
|
||||
### 즉시 업데이트
|
||||
- 새 공고 등록 시
|
||||
- 마감일 변경 시
|
||||
- 지원 조건 변경 시
|
||||
|
||||
### 캐시 정책
|
||||
- API 응답: 1시간 캐시
|
||||
- 데이터 저장: 24시간 캐시
|
||||
- 최종 결과: 5분 캐시
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
# 스타트업 지원사업 분류
|
||||
|
||||
## 1. 지원 유형별 분류
|
||||
|
||||
### 보조금
|
||||
**특징**: 상환 불필요한 정부 지원 금액
|
||||
**대상**: 초기 스타트업, R&D 중소기업
|
||||
**기관**: 중소벤처기업부, 지자체
|
||||
|
||||
**주요 프로그램**:
|
||||
- 서울시 청년 스타트업 창업 지원금
|
||||
- 경기도 MVP 개발 지원사업
|
||||
- 부산시 스타트업 보육 지원
|
||||
- 중소기업 기술개발 보조금
|
||||
|
||||
### 융자
|
||||
**특징**: 저리 융자 (상환 필요)
|
||||
**대상**: 성장 단계 스타트업
|
||||
**기관**: 중소기업진흥공단, 금융공단
|
||||
|
||||
**주요 프로그램**:
|
||||
- 스타트업 성장지원 융자
|
||||
- 중소기업 기술보증 융자
|
||||
- 청년창업 보증지원
|
||||
|
||||
### 투자
|
||||
**특징**: 자금 투자 (지분 참여)
|
||||
**대상**: 성장 잠재력 있는 스타트업
|
||||
**기관**: 벤처캐피탈, 정부투자기관
|
||||
|
||||
**주요 프로그램**:
|
||||
- KDB산업은행 벤처투자
|
||||
- 중소벤처기업공단 투자
|
||||
- 지역별 스타트업 투자펀드
|
||||
|
||||
### 멘토링/교육
|
||||
**특징**: 전문가 멘토링, 교육 프로그램
|
||||
**대상**: 모든 단계 스타트업
|
||||
**기관**: 창업진흥원, 민간 기관
|
||||
|
||||
**주요 프로그램**:
|
||||
- 스타트업 비즈니스 멘토링
|
||||
- 창업아이디어 개발 교육
|
||||
- 기술 스타트업 양성 프로그램
|
||||
|
||||
### 인프라 지원
|
||||
**특징**: 물리적 공간, 장비 지원
|
||||
**대상**: 공간 필요 스타트업
|
||||
**기관**: 지자체, 공공기관
|
||||
|
||||
**주요 프로그램**:
|
||||
- 창업 보육 센터 입주
|
||||
- 기술장비 공유
|
||||
- 테스트베드 지원
|
||||
|
||||
## 2. 지역별 분류
|
||||
|
||||
### 서울특별시
|
||||
**주요 기관**: 서울시 창업진흥원
|
||||
**특징**: 대도시 기반 다양한 지원
|
||||
|
||||
**프로그램 유형**:
|
||||
- 청년 창업 지원
|
||||
- 기술 스타트업 지원
|
||||
- 해외 진출 지원
|
||||
- 문화콘텐츠 스타트업 지원
|
||||
|
||||
### 경기도
|
||||
**주요 기관**: 경기도 창업진흥원
|
||||
**특징**: 수도권 중심 기술 집약적
|
||||
|
||||
**프로그램 유형**:
|
||||
- MVP 개발 지원
|
||||
- 기술 이전 지원
|
||||
- 중소기업 혁신 지원
|
||||
- 바이오 스타트업 지원
|
||||
|
||||
### 부산광역시
|
||||
**주요 기관**: 부산시 스타트업 허브
|
||||
**특징**: 해양, 조선 산업 특화
|
||||
|
||||
**프로그램 유형**:
|
||||
- 해양 스타트업 지원
|
||||
- 스마트시티 지원
|
||||
- 관광 스타트업 지원
|
||||
- 제조업 스타트업 지원
|
||||
|
||||
### 광주광역시
|
||||
**주요 기관**: 광주창업파크
|
||||
**특징**: 전통 공업 도시 전환
|
||||
|
||||
**프로그램 유형**:
|
||||
- 전자 스타트업 지원
|
||||
- 지역 특화 산업 지원
|
||||
- 청년 창업 아카데미
|
||||
- 기술 이전 지원
|
||||
|
||||
### 대구광역시
|
||||
**주요 기관**: 대구창업진흥원
|
||||
**특징**: 제조업 중심 스마트화
|
||||
|
||||
**프로그램 유형**:
|
||||
- 스마트제조 지원
|
||||
- IT 스타트업 지원
|
||||
- 의료 스타트업 지원
|
||||
- 중소기업 디지털 전환 지원
|
||||
|
||||
## 3. 산업 분야별 분류
|
||||
|
||||
### IT/소프트웨어
|
||||
**특징**: 디지털 전반 지원
|
||||
**주요 프로그램**:
|
||||
- 소프트웨어 개발 지원
|
||||
- AI/빅데이터 지원
|
||||
- 클라우드 서비스 지원
|
||||
|
||||
### 바이오/의료
|
||||
**특징**: 높은 진입 장벽, 장기 개발
|
||||
**주요 프로그램**:
|
||||
- 의료기기 개발 지원
|
||||
- 바이오 기술 개발 지원
|
||||
- 헬스케어 스타트업 지원
|
||||
|
||||
### 제조업
|
||||
**특징**: 자본 집약적, 기술 집약적
|
||||
**주요 프로그램**:
|
||||
- 스마트제조 지원
|
||||
- 공정 혁신 지원
|
||||
- 재료 개발 지원
|
||||
|
||||
### 에너지/환경
|
||||
**특징**: 친환경 기술 중심
|
||||
**주요 프로그램**:
|
||||
- 신에너지 기술 지원
|
||||
- 환경 기술 개발 지원
|
||||
- 탄소중립 기술 지원
|
||||
|
||||
### 문화/콘텐츠
|
||||
**특징**: 창의성 중심
|
||||
**주요 프로그램**:
|
||||
- 게임 개발 지원
|
||||
- 콘텐츠 제작 지원
|
||||
- 크리에이티브 산업 지원
|
||||
|
||||
### 금융/핀테크
|
||||
**특징**: 규제 산업
|
||||
**주요 프로그램**:
|
||||
- 핀테크 서비스 지원
|
||||
- 블록체인 기술 지원
|
||||
- 디지털 금융 지원
|
||||
|
||||
## 4. 창업 단계별 분류
|
||||
|
||||
### 아이디어 단계
|
||||
**특징**: 초기 구체화 단계
|
||||
**지원 내용**:
|
||||
- 아이디어 개발 교육
|
||||
- 시장조사 지원
|
||||
- 비즈니스 플랜 작성 지원
|
||||
|
||||
### 초기 단계 (Pre-seed)
|
||||
**특징**: 제품 개발 시작
|
||||
**지원 내용**:
|
||||
- MVP 개발 지원
|
||||
- 초기 자금 조달
|
||||
- 창업 보육 센터 입주
|
||||
|
||||
### 성장 단계 (Seed)
|
||||
**특징**: 제품 출시, 고객 확보
|
||||
**지원 내용**:
|
||||
- 시장 진출 지원
|
||||
- 투자 유치 지원
|
||||
- 기술 개발 지원
|
||||
|
||||
### 확장 단계 (Growth)
|
||||
**특징**: 시장 점유율 확대
|
||||
**지원 내용**:
|
||||
- 글로벌 진출 지원
|
||||
- 대규모 투자 유치
|
||||
- 인프라 확장 지원
|
||||
|
||||
### 성숙 단계 (Mature)
|
||||
**특징**: 안정화 단계
|
||||
**지원 내용**:
|
||||
- 기술 고도화 지원
|
||||
- M&A 지원
|
||||
- 국제화 지원
|
||||
|
||||
## 5. 대상별 분류
|
||||
|
||||
### 청년 창업가
|
||||
**연령**: 만 19~34세
|
||||
**특징**: 초기 창업, 경험 부족
|
||||
**주요 지원**:
|
||||
- 청년 창업 보조금
|
||||
- 창업 교육 프로그램
|
||||
- 멘토링 지원
|
||||
|
||||
### 여성 창업가
|
||||
**특징**: 일-가정 병행 지원
|
||||
**주요 지원**:
|
||||
- 여성 창업 보조금
|
||||
- 육아 지원 서비스
|
||||
- 네트워킹 지원
|
||||
|
||||
### 장애인 창업가
|
||||
**특징**: 접근성 지원 필요
|
||||
**주요 지원**:
|
||||
- 장애인 창업 지원금
|
||||
- 장애인 고용 지원
|
||||
- 접근성 컨설팅
|
||||
|
||||
### 외국인 창업가
|
||||
**특징**: 규제, 문화 장벽
|
||||
**주요 지원**:
|
||||
- 외국인 창업 지원금
|
||||
- 법률/세무 컨설팅
|
||||
- 한글/한국어 교육
|
||||
|
||||
### 기술 창업가
|
||||
**특징**: R&D 중심
|
||||
**주요 지원**:
|
||||
- 기술 개발 지원
|
||||
- 특허 출원 지원
|
||||
- 연구 인프라 지원
|
||||
|
|
@ -1,334 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
import os
|
||||
|
||||
class StartupSupportAPI:
|
||||
"""스타트업 지원사업 API 클라이언트"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_urls = {
|
||||
'seoul': 'https://seoulstartup.go.kr',
|
||||
'gyeonggi': 'https://g-startup.kr',
|
||||
'busan': 'https://busanstartup.kr',
|
||||
'gwangju': 'https://startup.gwangju.kr',
|
||||
'daegu': 'https://daegu-startup.kr',
|
||||
'nationwide': 'https://www.data.go.kr'
|
||||
}
|
||||
|
||||
# 공공데이터포털 API 키 (환경 변수에서 가져오기)
|
||||
self.data_go_kr_api_key = os.getenv('DATA_GO_KR_API_KEY')
|
||||
|
||||
# 헤더 설정
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def search_programs(self, region: str = '전국', keyword: Optional[str] = None,
|
||||
support_type: Optional[str] = None, deadline_only: bool = False) -> List[Dict]:
|
||||
"""
|
||||
지원사업 검색
|
||||
|
||||
Args:
|
||||
region: 지역 (서울특별시, 경기도, 부산광역시 등)
|
||||
keyword: 검색 키워드
|
||||
support_type: 지원 유형 (보조금, 융자, 멘토링 등)
|
||||
deadline_only: 마감 임박 사업만 검색
|
||||
|
||||
Returns:
|
||||
지원사업 목록
|
||||
"""
|
||||
programs = []
|
||||
|
||||
# 1. 공공데이터포털 API 호출
|
||||
if self.data_go_kr_api_key:
|
||||
data_go_kr_programs = self._search_data_go_kr(region, keyword, support_type)
|
||||
programs.extend(data_go_kr_programs)
|
||||
|
||||
# 2. 지자체별 API 호출
|
||||
region_programs = self._search_by_region(region, keyword, support_type)
|
||||
programs.extend(region_programs)
|
||||
|
||||
# 3. 마감 임박 필터링
|
||||
if deadline_only:
|
||||
programs = self._filter_upcoming_deadline(programs)
|
||||
|
||||
# 중복 제거
|
||||
programs = self._remove_duplicates(programs)
|
||||
|
||||
# 정렬
|
||||
programs = self._sort_programs(programs)
|
||||
|
||||
return programs
|
||||
|
||||
def _search_data_go_kr(self, region: str, keyword: Optional[str], support_type: Optional[str]) -> List[Dict]:
|
||||
"""공공데이터포털 API로 검색"""
|
||||
programs = []
|
||||
|
||||
try:
|
||||
# 중소벤처기업부 스타트업 지원사업 API
|
||||
url = "https://www.data.go.kr/api/15058530/openapi"
|
||||
|
||||
params = {
|
||||
'serviceKey': self.data_go_kr_api_key,
|
||||
'pageNo': '1',
|
||||
'numOfRows': '100',
|
||||
'_type': 'json'
|
||||
}
|
||||
|
||||
if region and region != '전국':
|
||||
params['cnpCdNm'] = region
|
||||
|
||||
if keyword:
|
||||
params['panNm'] = keyword
|
||||
|
||||
response = requests.get(url, params=params, headers=self.headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# 실제 API 응답 구조에 따라 데이터 추출
|
||||
if 'items' in data:
|
||||
for item in data['items']:
|
||||
program = self._parse_program_from_data_go_kr(item)
|
||||
if program:
|
||||
programs.append(program)
|
||||
|
||||
except Exception as e:
|
||||
print(f"공공데이터포털 API 오류: {e}")
|
||||
|
||||
return programs
|
||||
|
||||
def _search_by_region(self, region: str, keyword: Optional[str], support_type: Optional[str]) -> List[Dict]:
|
||||
"""지자체별 API로 검색"""
|
||||
programs = []
|
||||
|
||||
# 지자체별 API 엔드포인트
|
||||
region_apis = {
|
||||
'서울특별시': {
|
||||
'url': 'https://seoulstartup.go.kr/api/program/list',
|
||||
'method': 'GET'
|
||||
},
|
||||
'경기도': {
|
||||
'url': 'https://g-startup.kr/api/support/list',
|
||||
'method': 'GET'
|
||||
},
|
||||
'부산광역시': {
|
||||
'url': 'https://busanstartup.kr/api/program/list',
|
||||
'method': 'GET'
|
||||
},
|
||||
'광주광역시': {
|
||||
'url': 'https://startup.gwangju.kr/api/support/list',
|
||||
'method': 'GET'
|
||||
},
|
||||
'대구광역시': {
|
||||
'url': 'https://daegu-startup.kr/api/program/list',
|
||||
'method': 'GET'
|
||||
}
|
||||
}
|
||||
|
||||
target_regions = list(region_apis) if region == '전국' else [region]
|
||||
for target_region in target_regions:
|
||||
if target_region not in region_apis:
|
||||
continue
|
||||
api_info = region_apis[target_region]
|
||||
|
||||
try:
|
||||
params = {}
|
||||
if keyword:
|
||||
params['keyword'] = keyword
|
||||
if support_type:
|
||||
params['type'] = support_type
|
||||
|
||||
response = requests.get(api_info['url'], params=params,
|
||||
headers=self.headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# 실제 API 응답 구조에 따라 데이터 추출
|
||||
if 'programs' in data:
|
||||
for item in data['programs']:
|
||||
program = self._parse_program_from_region_api(item, target_region)
|
||||
if program:
|
||||
programs.append(program)
|
||||
|
||||
except Exception as e:
|
||||
print(f"{target_region} API 오류: {e}")
|
||||
|
||||
return programs
|
||||
|
||||
def _parse_program_from_data_go_kr(self, item: Dict) -> Optional[Dict]:
|
||||
"""공공데이터포털 응답 파싱"""
|
||||
try:
|
||||
program = {
|
||||
'id': f"data_gov_{item.get('pan_id', '')}",
|
||||
'title': item.get('pan_nm', ''),
|
||||
'organization': '중소벤처기업부',
|
||||
'region': item.get('cnp_cd_nm', '전국'),
|
||||
'support_type': item.get('support_type', '기타'),
|
||||
'amount': item.get('amount', '정보 없음'),
|
||||
'deadline': item.get('clsg_dt', ''),
|
||||
'target': item.get('target', '전체 대상'),
|
||||
'contact': item.get('contact', '02-1234-5678'),
|
||||
'url': item.get('detail_url', ''),
|
||||
'source': '공공데이터포털',
|
||||
'last_updated': item.get('last_updated', datetime.now().strftime('%Y-%m-%d'))
|
||||
}
|
||||
|
||||
# 필수 필드 검증
|
||||
if not program['title']:
|
||||
return None
|
||||
|
||||
return program
|
||||
|
||||
except Exception as e:
|
||||
print(f"공공데이터포털 데이터 파싱 오류: {e}")
|
||||
return None
|
||||
|
||||
def _parse_program_from_region_api(self, item: Dict, region: str) -> Optional[Dict]:
|
||||
"""지자체 API 응답 파싱"""
|
||||
try:
|
||||
program = {
|
||||
'id': f"{region}_{item.get('id', '')}",
|
||||
'title': item.get('title', ''),
|
||||
'organization': region + ' 창업진흥원',
|
||||
'region': region,
|
||||
'support_type': item.get('type', '기타'),
|
||||
'amount': item.get('amount', '정보 없음'),
|
||||
'deadline': item.get('deadline', ''),
|
||||
'target': item.get('target', '전체 대상'),
|
||||
'contact': item.get('contact', '02-1234-5678'),
|
||||
'url': item.get('url', ''),
|
||||
'source': region + ' 창업진흥원',
|
||||
'last_updated': item.get('last_updated', datetime.now().strftime('%Y-%m-%d'))
|
||||
}
|
||||
|
||||
# 필수 필드 검증
|
||||
if not program['title']:
|
||||
return None
|
||||
|
||||
return program
|
||||
|
||||
except Exception as e:
|
||||
print(f"지자체 API 데이터 파싱 오류: {e}")
|
||||
return None
|
||||
|
||||
def _filter_upcoming_deadline(self, programs: List[Dict]) -> List[Dict]:
|
||||
"""마감 임박 사업 필터링"""
|
||||
today = datetime.now()
|
||||
upcoming_threshold = today + timedelta(days=7) # 7일 이내
|
||||
|
||||
filtered = []
|
||||
|
||||
for program in programs:
|
||||
if program['deadline']:
|
||||
try:
|
||||
deadline = datetime.strptime(program['deadline'], '%Y-%m-%d')
|
||||
if today <= deadline <= upcoming_threshold:
|
||||
filtered.append(program)
|
||||
except:
|
||||
# 날짜 파싱 실패 시 제외
|
||||
continue
|
||||
|
||||
return filtered
|
||||
|
||||
def _remove_duplicates(self, programs: List[Dict]) -> List[Dict]:
|
||||
"""중복 제거"""
|
||||
seen_ids = set()
|
||||
unique_programs = []
|
||||
|
||||
for program in programs:
|
||||
program_id = program['id']
|
||||
if program_id not in seen_ids:
|
||||
seen_ids.add(program_id)
|
||||
unique_programs.append(program)
|
||||
|
||||
return unique_programs
|
||||
|
||||
def _sort_programs(self, programs: List[Dict]) -> List[Dict]:
|
||||
"""사업 정렬"""
|
||||
# 마감일 기준으로 정렬 (가까운 순)
|
||||
def get_deadline(program):
|
||||
if program['deadline']:
|
||||
try:
|
||||
return datetime.strptime(program['deadline'], '%Y-%m-%d')
|
||||
except:
|
||||
return datetime.max
|
||||
return datetime.max
|
||||
|
||||
return sorted(programs, key=get_deadline)
|
||||
|
||||
def get_program_detail(self, program_id: str) -> Optional[Dict]:
|
||||
"""특정 지원사업 상세 정보 조회"""
|
||||
# ID에 따라 적절한 소스에서 상세 정보 조회
|
||||
if program_id.startswith('data_gov_'):
|
||||
return self._get_data_go_kr_detail(program_id)
|
||||
elif any(region in program_id for region in ['서울', '경기', '부산', '광주', '대구']):
|
||||
return self._get_region_detail(program_id)
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_data_go_kr_detail(self, program_id: str) -> Optional[Dict]:
|
||||
"""공공데이터포털 상세 정보 조회"""
|
||||
return None
|
||||
|
||||
def _get_region_detail(self, program_id: str) -> Optional[Dict]:
|
||||
"""지자체 상세 정보 조회"""
|
||||
return None
|
||||
|
||||
def search_startup_support(region: str = '전국', keyword: Optional[str] = None,
|
||||
support_type: Optional[str] = None, deadline_only: bool = False) -> List[Dict]:
|
||||
"""
|
||||
스타트업 지원사업 검색 함수
|
||||
|
||||
Args:
|
||||
region: 지역 (서울특별시, 경기도, 부산광역시 등)
|
||||
keyword: 검색 키워드
|
||||
support_type: 지원 유형 (보조금, 융자, 멘토링 등)
|
||||
deadline_only: 마감 임박 사업만 검색
|
||||
|
||||
Returns:
|
||||
지원사업 목록
|
||||
"""
|
||||
api = StartupSupportAPI()
|
||||
return api.search_programs(region, keyword, support_type, deadline_only)
|
||||
|
||||
def get_startup_program_detail(program_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
특정 지원사업 상세 정보 조회 함수
|
||||
|
||||
Args:
|
||||
program_id: 지원사업 ID
|
||||
|
||||
Returns:
|
||||
지원사업 상세 정보
|
||||
"""
|
||||
api = StartupSupportAPI()
|
||||
return api.get_program_detail(program_id)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트용 실행
|
||||
print("스타트업 지원사업 검색 테스트")
|
||||
|
||||
# 전체 검색
|
||||
programs = search_startup_support()
|
||||
print(f"총 {len(programs)}개 지원사업 발견")
|
||||
|
||||
# 서울 검색
|
||||
seoul_programs = search_startup_support(region='서울특별시')
|
||||
print(f"서울 지원사업: {len(seoul_programs)}개")
|
||||
|
||||
# 키워드 검색
|
||||
keyword_programs = search_startup_support(keyword='청년')
|
||||
print(f"'청년' 키워드 검색 결과: {len(keyword_programs)}개")
|
||||
|
||||
# 마감 임박 검색
|
||||
deadline_programs = search_startup_support(deadline_only=True)
|
||||
print(f"마감 임박 지원사업: {len(deadline_programs)}개")
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 현재 디렉토리에서 모듈 임포트
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from startup_support import search_startup_support, get_startup_program_detail, StartupSupportAPI
|
||||
|
||||
class TestStartupSupport(unittest.TestCase):
|
||||
"""스타트업 지원사업 API 테스트"""
|
||||
|
||||
def setUp(self):
|
||||
"""테스트 초기화"""
|
||||
soon_deadline = (datetime.now() + timedelta(days=5)).strftime('%Y-%m-%d')
|
||||
later_deadline = (datetime.now() + timedelta(days=10)).strftime('%Y-%m-%d')
|
||||
self.test_programs = [
|
||||
{
|
||||
'id': 'test_001',
|
||||
'title': '서울시 청년 스타트업 창업 지원금',
|
||||
'organization': '서울시',
|
||||
'region': '서울특별시',
|
||||
'support_type': '보조금',
|
||||
'amount': '최대 5천만원',
|
||||
'deadline': later_deadline,
|
||||
'target': '만 19~34세 청년 창업가',
|
||||
'contact': '02-1234-5678',
|
||||
'url': 'https://seoulstartup.go.kr/program/001',
|
||||
'source': '서울시 창업플러스',
|
||||
'last_updated': '2024-05-20'
|
||||
},
|
||||
{
|
||||
'id': 'test_002',
|
||||
'title': '경기도 MVP 지원사업',
|
||||
'organization': '경기도',
|
||||
'region': '경기도',
|
||||
'support_type': '보조금',
|
||||
'amount': '최대 3천만원',
|
||||
'deadline': soon_deadline,
|
||||
'target': 'MVP 개발 스타트업',
|
||||
'contact': '031-1234-5678',
|
||||
'url': 'https://g-startup.kr/program/002',
|
||||
'source': '경기도 창업진흥원',
|
||||
'last_updated': '2024-05-20'
|
||||
}
|
||||
]
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._search_data_go_kr')
|
||||
@patch('startup_support.StartupSupportAPI._search_by_region')
|
||||
def test_search_programs_basic(self, mock_region_search, mock_data_go_kr_search):
|
||||
"""기본 검색 테스트"""
|
||||
# 모킹 설정
|
||||
mock_data_go_kr_search.return_value = []
|
||||
mock_region_search.return_value = self.test_programs
|
||||
|
||||
# 검색 실행
|
||||
result = search_startup_support()
|
||||
|
||||
# 결과 확인
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertEqual(result[0]['title'], '경기도 MVP 지원사업')
|
||||
self.assertEqual(result[1]['title'], '서울시 청년 스타트업 창업 지원금')
|
||||
|
||||
@patch('startup_support.requests.get')
|
||||
def test_nationwide_search_aggregates_configured_regions_without_api_key(self, mock_get):
|
||||
payloads = {
|
||||
'https://seoulstartup.go.kr/api/program/list': {
|
||||
'programs': [{
|
||||
'id': 'seoul_1',
|
||||
'title': '서울 창업 지원',
|
||||
'deadline': '2026-06-03',
|
||||
}]
|
||||
},
|
||||
'https://g-startup.kr/api/support/list': {
|
||||
'programs': [{
|
||||
'id': 'gyeonggi_1',
|
||||
'title': '경기 창업 지원',
|
||||
'deadline': '2026-06-04',
|
||||
}]
|
||||
},
|
||||
}
|
||||
|
||||
def fake_get(url, **_):
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.json.return_value = payloads.get(url, {'programs': []})
|
||||
return response
|
||||
|
||||
mock_get.side_effect = fake_get
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
result = search_startup_support(region='전국')
|
||||
|
||||
titles = {program['title'] for program in result}
|
||||
self.assertIn('서울 창업 지원', titles)
|
||||
self.assertIn('경기 창업 지원', titles)
|
||||
|
||||
def test_builtin_detail_lookup_does_not_return_fabricated_sample_data(self):
|
||||
self.assertIsNone(get_startup_program_detail('data_gov_missing'))
|
||||
self.assertIsNone(get_startup_program_detail('서울_missing'))
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._search_data_go_kr')
|
||||
@patch('startup_support.StartupSupportAPI._search_by_region')
|
||||
def test_search_programs_seoul_only(self, mock_region_search, mock_data_go_kr_search):
|
||||
"""서울 지역 검색 테스트"""
|
||||
# 모킹 설정
|
||||
mock_data_go_kr_search.return_value = []
|
||||
mock_region_search.return_value = [self.test_programs[0]] # 서울 프로그램만
|
||||
|
||||
# 검색 실행
|
||||
result = search_startup_support(region='서울특별시')
|
||||
|
||||
# 결과 확인
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['region'], '서울특별시')
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._search_data_go_kr')
|
||||
@patch('startup_support.StartupSupportAPI._search_by_region')
|
||||
def test_search_programs_keyword_search(self, mock_region_search, mock_data_go_kr_search):
|
||||
"""키워드 검색 테스트"""
|
||||
# 모킹 설정
|
||||
mock_data_go_kr_search.return_value = []
|
||||
mock_region_search.return_value = [self.test_programs[1]] # MVP 프로그램만
|
||||
|
||||
# 검색 실행
|
||||
result = search_startup_support(keyword='MVP')
|
||||
|
||||
# 결과 확인
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0]['title'], '경기도 MVP 지원사업')
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._search_data_go_kr')
|
||||
@patch('startup_support.StartupSupportAPI._search_by_region')
|
||||
def test_search_programs_deadline_only(self, mock_region_search, mock_data_go_kr_search):
|
||||
"""마감 임박 검색 테스트"""
|
||||
# 모킹 설정
|
||||
mock_data_go_kr_search.return_value = []
|
||||
mock_region_search.return_value = self.test_programs
|
||||
|
||||
# 검색 실행
|
||||
result = search_startup_support(deadline_only=True)
|
||||
|
||||
# 결과 확인 (7일 이내 마감만)
|
||||
self.assertEqual(len(result), 1)
|
||||
for program in result:
|
||||
deadline = datetime.strptime(program['deadline'], '%Y-%m-%d')
|
||||
self.assertTrue(datetime.now() <= deadline <= datetime.now() + timedelta(days=7))
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._get_data_go_kr_detail')
|
||||
def test_get_program_detail_data_gov(self, mock_get_detail):
|
||||
"""공공데이터포털 상세 정보 조회 테스트"""
|
||||
# 모킹 설정
|
||||
mock_get_detail.return_value = self.test_programs[0]
|
||||
|
||||
# 상세 정보 조회
|
||||
result = get_startup_program_detail('data_gov_test_001')
|
||||
|
||||
# 결과 확인
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result['title'], '서울시 청년 스타트업 창업 지원금')
|
||||
|
||||
@patch('startup_support.StartupSupportAPI._get_region_detail')
|
||||
def test_get_program_detail_region(self, mock_get_detail):
|
||||
"""지자체 상세 정보 조회 테스트"""
|
||||
# 모킹 설정
|
||||
mock_get_detail.return_value = self.test_programs[1]
|
||||
|
||||
# 상세 정보 조회
|
||||
result = get_startup_program_detail('서울_test_001')
|
||||
|
||||
# 결과 확인
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result['title'], '경기도 MVP 지원사업')
|
||||
|
||||
def test_parse_program_from_data_go_kr(self):
|
||||
"""공공데이터포털 데이터 파싱 테스트"""
|
||||
api = StartupSupportAPI()
|
||||
|
||||
# 테스트 데이터
|
||||
item = {
|
||||
'pan_id': 'test_001',
|
||||
'pan_nm': '테스트 지원사업',
|
||||
'cnp_cd_nm': '서울특별시',
|
||||
'support_type': '보조금',
|
||||
'amount': '최대 5천만원',
|
||||
'clsg_dt': '2024-12-31',
|
||||
'target': '청년 창업가',
|
||||
'contact': '02-1234-5678',
|
||||
'detail_url': 'https://test.com',
|
||||
'last_updated': '2024-05-20'
|
||||
}
|
||||
|
||||
# 파싱 실행
|
||||
result = api._parse_program_from_data_go_kr(item)
|
||||
|
||||
# 결과 확인
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result['title'], '테스트 지원사업')
|
||||
self.assertEqual(result['region'], '서울특별시')
|
||||
self.assertEqual(result['support_type'], '보조금')
|
||||
|
||||
def test_parse_program_from_region_api(self):
|
||||
"""지자체 API 데이터 파싱 테스트"""
|
||||
from startup_support import StartupSupportAPI
|
||||
|
||||
api = StartupSupportAPI()
|
||||
|
||||
# 테스트 데이터
|
||||
item = {
|
||||
'id': 'test_001',
|
||||
'title': '테스트 지원사업',
|
||||
'type': '융자',
|
||||
'amount': '최대 1억원',
|
||||
'deadline': '2024-12-31',
|
||||
'target': '중소기업',
|
||||
'contact': '02-1234-5678',
|
||||
'url': 'https://test.com',
|
||||
'last_updated': '2024-05-20'
|
||||
}
|
||||
|
||||
# 파싱 실행
|
||||
result = api._parse_program_from_region_api(item, '경기도')
|
||||
|
||||
# 결과 확인
|
||||
self.assertIsNotNone(result)
|
||||
assert result is not None
|
||||
self.assertEqual(result['title'], '테스트 지원사업')
|
||||
self.assertEqual(result['organization'], '경기도 창업진흥원')
|
||||
self.assertEqual(result['support_type'], '융자')
|
||||
|
||||
def test_filter_upcoming_deadline(self):
|
||||
"""마감 임박 필터링 테스트"""
|
||||
from startup_support import StartupSupportAPI
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
api = StartupSupportAPI()
|
||||
|
||||
# 테스트 데이터 (다양한 마감일)
|
||||
programs = [
|
||||
{'deadline': (datetime.now() + timedelta(days=3)).strftime('%Y-%m-%d')}, # 3일 후
|
||||
{'deadline': (datetime.now() + timedelta(days=10)).strftime('%Y-%m-%d')}, # 10일 후
|
||||
{'deadline': (datetime.now() - timedelta(days=5)).strftime('%Y-%m-%d')}, # 5일 전
|
||||
{'deadline': '2024-12-31'}, # 먼 미래
|
||||
{'deadline': ''} # 마감일 없음
|
||||
]
|
||||
|
||||
# 필터링 실행
|
||||
result = api._filter_upcoming_deadline(programs)
|
||||
|
||||
# 결과 확인 (7일 이내이면서 이미 지난 날짜 제외)
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
def test_remove_duplicates(self):
|
||||
"""중복 제거 테스트"""
|
||||
from startup_support import StartupSupportAPI
|
||||
|
||||
api = StartupSupportAPI()
|
||||
|
||||
# 테스트 데이터 (중복 포함)
|
||||
programs = [
|
||||
{'id': 'test_001', 'title': '프로그램 A'},
|
||||
{'id': 'test_002', 'title': '프로그램 B'},
|
||||
{'id': 'test_001', 'title': '프로그램 A (중복)'},
|
||||
{'id': 'test_003', 'title': '프로그램 C'}
|
||||
]
|
||||
|
||||
# 중복 제거 실행
|
||||
result = api._remove_duplicates(programs)
|
||||
|
||||
# 결과 확인 (중복 제외)
|
||||
self.assertEqual(len(result), 3)
|
||||
self.assertEqual(result[0]['id'], 'test_001')
|
||||
self.assertEqual(result[1]['id'], 'test_002')
|
||||
self.assertEqual(result[2]['id'], 'test_003')
|
||||
|
||||
def run_tests():
|
||||
"""테스트 실행"""
|
||||
# 테스트 스위트 생성
|
||||
suite = unittest.TestLoader().loadTestsFromTestCase(TestStartupSupport)
|
||||
|
||||
# 테스트 실행기 생성
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
|
||||
# 테스트 실행
|
||||
result = runner.run(suite)
|
||||
|
||||
return result.wasSuccessful()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("스타트업 지원사업 API 테스트 시작")
|
||||
|
||||
# 테스트 실행
|
||||
success = run_tests()
|
||||
|
||||
if success:
|
||||
print("✅ 모든 테스트 통과!")
|
||||
else:
|
||||
print("❌ 일부 테스트 실패")
|
||||
sys.exit(1)
|
||||
Loading…
Add table
Add a link
Reference in a new issue