@nomadamas/k-skill 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,124 @@
1
+ # BC카드 eat.pl 맛집 검색
2
+
3
+ ## What this skill does
4
+
5
+ 한국에서 맛집, 음식점, 식당, 밥집, 카페, 고깃집, 회식 장소를 찾을 때 쓴다. 사용자가 말한 장소를 먼저 `kakao-map`으로 정확한 도로명주소로 해석한 뒤, 그 주소를 중심으로 BC카드 eat.pl 잇플의 가맹점 검색을 조회한다. 결과는 반환 매출 랭킹 상위 순서를 보존하며, 사용자의 장르·동네·외국인 방문·회식·영업시간·혼잡 시간 조건으로 좁혀 표로 제시한다.
6
+
7
+ 추천은 별점 순위가 아니라 최근 1개월 BC카드 결제 데이터 기반 매출 순이다. 매출액(원)은 없고, 대신 `allSaleRnk`/`ccgSaleRnk` 백분위로 **얼마나 상위인지**를 반드시 숫자로 보여 준다. `srtTime`/`endTime`은 **영업 예상 시간**으로 정리한다. 모든 행에 eat.pl 상세 링크를 첨부한다.
8
+
9
+ ## 사용자 답변 규칙 (필수)
10
+
11
+ 사용자에게 보이는 답변에는 구현·연동 세부사항을 **절대 쓰지 않는다.** 결과는 맛집 추천처럼만 보여 준다.
12
+
13
+ 답변에 넣지 말 것:
14
+
15
+ - endpoint, URL 경로, HTTP 메서드, curl, 프록시, 서버, 환경변수, 기관코드, 토큰, 상태코드
16
+ - 내부 필드명 (`location`, `merTpbuzNm`, `allSaleRnk`, `ccgSaleRnk`, `merUrl`, `frnrVsitYn`, `empyVsitYn`, `srtTime` 등)
17
+ - `k-skill-proxy`, 릴레이, Lightsail, gpu01, API, JSON, 캐시, 좌표 체계
18
+
19
+ 표의 전국·지역 매출 상위 %, 영업 예상 시간, `[eat.pl 상세](url)`, 출처 한 줄은 출력 계약대로 유지한다. 실패해도 오류 코드나 엔드포인트를 말하지 말고, 그 조건으로는 매장을 찾지 못했다고만 안내한다.
20
+
21
+ ## When to use
22
+
23
+ 먹을 곳, 식당, 카페를 달라는 요청이면 이 스킬을 쓴다. 예:
24
+
25
+ - "맛집 추천해줘", "근처 음식점", "밥 먹을 곳", "분위기 좋은 식당"
26
+ - "강남역 근처 고기집 중 매출 높은 곳"
27
+ - "을지로입구역 주변 외국인이 많이 가는 카페"
28
+ - "성수동에서 회식하기 좋은 맛집"
29
+ - "이 동네 카페", "디저트 맛집", "회식 장소"
30
+
31
+ 술집·바만 찾는 요청은 `kakao-bar-nearby`를 우선한다.
32
+
33
+ ## Workflow
34
+
35
+ 아래 절차는 에이전트 내부 작업이다. 사용자 답변에 그대로 옮기지 않는다.
36
+
37
+ 1. 장소명이 모호하면 먼저 행정구역 또는 도로명주소를 확인한다. `kakao-map` 스킬을 우선 사용한다. 이 경로는 k-skill-proxy가 중계하는 **카카오 공식 Local REST API** (`GET /v1/kakao-map/search/keyword`, `coord2address` 등)이다. 카카오맵 웹 페이지를 스크래핑하지 않는다. 사용자가 지정한 경우 다른 지도 스킬로 교차 확인할 수 있다.
38
+ 2. 검색 중심은 장소의 정확한 주소에서 최소한 시·군·구와 도로명/동 단위로 정한다. eat.pl API는 텍스트 주소 부분일치 검색이며 반경(meter) 검색 API가 아니므로, API 결과를 실제 근처로 단정하지 말고 지도 스킬로 위치를 교차 확인한다.
39
+ 3. 다음 프록시 endpoint를 GET으로 호출한다.
40
+
41
+ ```bash
42
+ curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v1/bccard-eatpl/search" \
43
+ --data-urlencode 'location=서울 중구 을지로30길 20' \
44
+ --data-urlencode 'merTpbuzNm=카페'
45
+ ```
46
+
47
+ 지원 입력:
48
+
49
+ - `location` 또는 `address`: 정확한 주소/지역 문자열
50
+ - `merTpbuzNm`, `genre`, `business_type`: eat.pl 대분류 또는 소분류
51
+ - `merNm`, `merchant_name`: 가맹점명 일부
52
+
53
+ 4. 최대 100건을 받은 뒤 지도 검색 결과와 주소·상호를 대조하고, 요청 조건에 맞는 매장만 남긴다. 존재하지 않는 리뷰·메뉴·주차·영업 여부를 추측하지 않는다.
54
+ 5. 아래 **출력 계약**을 만족하는 표만 사용자에게 보여 준다. 계약을 빠뜨린 표는 미완성이다.
55
+ 6. 반드시 `eat.pl 잇플 · BC카드 결제 데이터 기반`을 출처로 표기한다.
56
+
57
+ ## 출력 계약 (필수)
58
+
59
+ 표 컬럼: `가맹점명`, `주소`, `업종`, `전국 매출 상위`, `지역 매출 상위`, `동네 맛집`, `외국인 방문`, `회식 적합`, `영업 예상 시간`, `혼잡 요일/시간`, `주요 고객층`, `eat.pl 상세 링크`.
60
+
61
+ ### 매출이 얼마나 높은지
62
+
63
+ - 매장마다 `전국 매출 상위 {allSaleRnk}%`, `지역 매출 상위 {ccgSaleRnk}%`를 빠짐없이 숫자로 쓴다. "매출이 높다"만 쓰지 않는다. 필드 이름 자체는 사용자에게 쓰지 않는다.
64
+ - 백분위 값이 **낮을수록** 최근 1개월 BC카드 결제 매출 순위가 높다. `상위 0.09%`는 `상위 3%`보다 훨씬 위다.
65
+ - 1% 미만은 소수점을 보존한다. 예: `0.04` → `전국 매출 상위 0.04%`.
66
+ - 실제 매출액(원)은 제공되지 않는다. 백분위가 높낮이 정보다.
67
+
68
+ ### 영업 예상 시간
69
+
70
+ - `srtTime`/`endTime`(HHMM)을 `영업 예상 시간 HH:MM–HH:MM`으로 변환한다. 예: `0702`+`2246` → `영업 예상 시간 07:02–22:46`.
71
+ - 종료 시각이 시작보다 이르면 자정 넘김으로 `영업 예상 시간 18:14–익일 05:56`처럼 적는다.
72
+ - 표 밖 한 줄로 "최근 1개월 결제 승인 시각 기반 추정이므로 공식 영업시간이 아님"을 고지한다.
73
+
74
+ ### 상세 링크 강제
75
+
76
+ - **모든 행**에 응답 `merUrl`을 마크다운 링크로 첨부한다: `[eat.pl 상세]({merUrl})`.
77
+ - `merUrl`이 없거나 `https://web.paybooc.ai/`로 시작하지 않으면 그 행을 출력하지 않는다.
78
+ - 링크가 빠진 목록·표는 완성 결과가 아니다. URL 경로를 조합하거나 추측하지 않는다.
79
+
80
+ ## Credentials and proxy operations
81
+
82
+ 사용자에게 credentials를 묻거나 출력하지 않는다. 운영 환경에 다음 값을 secret으로 주입한다.
83
+
84
+ - `BCCARD_EATPL_INST_NM`: 제휴 시 발급된 기관코드
85
+ - `BCCARD_EATPL_API_BASE_URL`: 필수값, 기본값 없음 (미설정 시 503 `upstream_not_configured`). 운영 URL은 `https://api.paybooc.ai/api/mer`
86
+ - `BCCARD_EATPL_RELAY_URL`: 선택값, 고정 outbound IP relay URL. 설정하면 proxy는 이 URL만 호출하고, 토큰 없이는 호출하지 않는다
87
+ - `BCCARD_EATPL_RELAY_TOKEN`: 선택값, relay `Authorization: Bearer` 시크릿. gpu01 `k-skill-proxy` `.env`와 Lightsail relay env에만 저장한다. 사용자·클라이언트는 Lightsail을 직접 호출하지 않는다
88
+ - `BCCARD_EATPL_API_TIMEOUT_MS`: 선택값, 기본 20000
89
+
90
+ `trnsTrceNo`는 사용자가 입력하지 않는다. proxy가 기관코드 + 한국시간 기준 `YYYYMMDD` + 10자리 순번으로 매 요청 생성한다.
91
+
92
+ ## Important interpretation
93
+
94
+ - 결과 자체가 매출 랭킹 상위 순이며, 별도 매출액은 제공되지 않는다. 높낮이는 전국·지역 백분위로만 말한다.
95
+ - `allSaleRnk`/`ccgSaleRnk`는 퍼센트 백분위이며 낮을수록 상위다. 사용자 답변에는 필드명 대신 `전국 매출 상위 N%`로만 쓴다.
96
+ - `srtTime`/`endTime`은 최근 1개월 결제 승인 시각 기반 **영업 예상 시간**이지 영업시간 공시가 아니다.
97
+ - 실시간 영업 여부는 eat.pl 상세 페이지를 확인해야 하며, 조회 시점 관측과 다를 수 있다.
98
+ - 전화번호는 유효하지 않을 수 있다.
99
+ - 좌표는 TM128이므로 WGS84 지도 좌표로 직접 해석하지 않는다. 이 사실을 사용자에게 설명하지 않는다.
100
+
101
+ ## Failure modes
102
+
103
+ 내부 처리 기준이다. 사용자에게 코드명을 말하지 않는다.
104
+
105
+ - `400 bad_request`: 주소·장르·가맹점명 중 하나도 없거나 입력이 잘못됨
106
+ - `503 upstream_not_configured`: `BCCARD_EATPL_INST_NM` 미설정
107
+ - `403/401 upstream_error`: IP 등록, 기관코드, 제휴 권한 또는 upstream 인증 문제
108
+ - `502 upstream_error`: eat.pl 연결·서버 오류
109
+ - `502 upstream_semantic_error`: `rspCode`가 성공이 아니거나 응답 구조가 명세와 다름
110
+ - 개발 서버 연결 타임아웃: 개발 접근 정보 또는 네트워크/IP allowlist를 BC카드 측에 확인
111
+ - 빈 결과: 주소 텍스트가 너무 좁거나 업종 표기가 맞지 않을 수 있으므로 임의 재시도하지 말고 주소 범위를 넓히거나 명세의 업종값을 확인
112
+
113
+ ## Legal and partnership notice
114
+
115
+ 이 스킬은 BC카드와의 공식 제휴 연동을 위해 제공되는 k-skill 구현물이라는 전제에서 사용한다. 다만 이 저장소와 k-skill 자체가 BC카드의 공식 제품·상표·지원 채널이라는 의미는 아니며, 실제 운영 전에는 BC카드의 제휴 승인·기관코드 발급·IP 등록·노출 문구 검수를 완료해야 한다. 출처는 항상 `eat.pl 잇플 · BC카드 결제 데이터 기반`으로 표시한다.
116
+
117
+ ## Done when
118
+
119
+ - 장소를 `kakao-map`(k-skill-proxy 카카오 공식 Local API)으로 주소로 해석했다.
120
+ - eat.pl 결과를 최대 100건까지 받아 지도 정보와 대조했다.
121
+ - 사용자의 조건으로 필터링한 표에 전국·지역 매출 상위 %, 영업 예상 시간, eat.pl 상세 링크가 **모든 행**에 있다.
122
+ - 매출 순 추천과 최근 1개월 결제 데이터 기준임을 설명했다.
123
+ - 출처·제휴 전제·데이터 한계를 함께 고지했다.
124
+ - 사용자 답변에 endpoint, 프록시, 환경변수, 내부 필드명이 없다.
@@ -0,0 +1,11 @@
1
+ # DISCLAIMER — `bccard-eatpl-search`
2
+
3
+ 이 스킬은 BC카드와 공식 제휴 연동을 전제로 설계된 구현물이다. 그러나 이 저장소, k-skill, 또는 이 스킬 자체가 BC카드·eat.pl 잇플의 공식 기능·공식 제품·공식 지원 채널·상표권자라는 뜻은 아니다. 서비스의 공식 제휴·협업 범위는 별도 계약으로 확인하며, 운영 노출 전에는 BC카드의 제휴 승인, 기관코드 발급, 호출 IP 등록, 개발·운영 endpoint 승인 및 표시 문구 검수를 완료해야 한다. 명칭은 기능 설명·조회 대상·링크 식별에만 사용한다.
4
+
5
+ 결과는 최근 1개월 BC카드 결제 데이터에서 집계된 가맹점 단위 정보다. 실제 매출액, 고객 개인 정보, 리뷰 평점, 현재 영업을 보증하지 않는다. `merUrl` 상세 페이지의 실시간 결제 기반 표시는 API의 분석 기반 영업 관측과 다를 수 있다.
6
+
7
+ 대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)과 대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)의 취지를 포함해, [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조), [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조), [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조), [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조) 등 관련 법령과 계약 조건을 별도로 준수해야 한다.
8
+
9
+ 이 스킬은 공개적으로 허용된 공식 API와 지도 서비스만 사용한다. CAPTCHA, 인증, IP 제한, 접근통제, quota 또는 서비스 운영 정책을 우회하지 않는다. 영업 방해나 서비스 장애를 일으키는 조직적·대량 크롤링, 재배포·데이터베이스 구축이 아니라 개인의 장소 선택을 위한 제한된 조회에 사용한다.
10
+
11
+ 이 문서는 법률 자문이나 BC카드의 최종 승인을 대신하지 않는다.
@@ -0,0 +1,7 @@
1
+ # 상표 사용 법적 고지 — `bccard-eatpl-search`
2
+
3
+ 이 스킬에서 `BC카드`, `eat.pl`, `잇플`, `paybooc.ai` 명칭은 연동 대상 서비스와 응답 상세 링크를 식별하기 위해 필요한 범위에서 사용한다. k-skill의 출처를 해당 권리자와 혼동시키려는 사용이 아니다. 실제 공식 제휴·후원·승인 범위는 별도의 계약과 검수로 확인해야 한다.
4
+
5
+ 대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)에 따른 출처 혼동 판단은 표시 태양과 사용 경위에 따라 별도로 검토해야 한다.
6
+
7
+ 따라서 명칭은 기능 설명과 링크 식별에 필요한 평문으로만 사용하고, 별도 근거 없이 로고·인증·파트너 표현을 사용하지 않는다. 이 문서는 법률 자문이나 BC카드의 최종 승인을 대신하지 않는다.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "bccard-eatpl-search",
3
+ "description": "대한민국 맛집, 음식점, 식당, 밥집, 카페, 커피, 디저트, 고깃집, 회식 장소를 지역·역·동네 기준으로 찾아 준다. Use when the user asks for 맛집, 음식점, 좋은 식당, 근처 카페, 밥 먹을 곳, 회식하기 좋은 곳, 분위기 좋은 맛집, or Korean restaurant and cafe recommendations. 별점이 아니라 최근 결제 데이터 기반 매출 상위로 정리한다.",
4
+ "profiles": ["proxy", "lookup"],
5
+ "frontmatter": "name: bccard-eatpl-search\ndescription: 대한민국 맛집, 음식점, 식당, 밥집, 카페, 커피, 디저트, 고깃집, 회식 장소를 지역·역·동네 기준으로 찾아 준다. Use when the user asks for 맛집, 음식점, 좋은 식당, 근처 카페, 밥 먹을 곳, 회식하기 좋은 곳, 분위기 좋은 맛집, or Korean restaurant and cafe recommendations. 별점이 아니라 최근 결제 데이터 기반 매출 상위로 정리한다.\nlicense: MIT\nmetadata:\n category: food\n locale: ko-KR\n phase: v1"
6
+ }
@@ -115,6 +115,7 @@ git -C ~/.agents/skills/korean-privacy-terms/upstream rev-parse HEAD
115
115
  - pin SHA 가 업스트림에서 삭제/force-push 된 경우: 스크립트가 SHA mismatch 로 비정상 종료한다. `scripts/upstream.pin` 을 최신 태그 SHA 로 bump 하고 PR 을 만든다.
116
116
  - Next.js Pages Router 단독 프로젝트: 업스트림이 실행을 중단한다. 사용자에게 App Router 전환이 선행 조건임을 안내한다.
117
117
  - 법률 개정 드리프트: 업스트림이 CHANGELOG 로 반영 기준을 관리한다. pin 만 올리지 말고 업스트림 CHANGELOG 를 함께 확인한다.
118
+ - 한글 이용약관 템플릿 0바이트 (upstream, #660): pin 시점 기준 `jurisdictions/kr-pipa/terms-of-service.ko.mdx.tmpl` 과 `templates/terms-of-service.mdx.tmpl` 이 빈 파일이라 한국어(`outputLocale: ko`) 경로에서 이용약관을 생성할 수 없다. 개인정보처리방침(ko/en)과 영문 약관은 정상 생성된다. 업스트림에 템플릿이 채워지면 `scripts/upstream.pin` bump 로 해소한다. `install.sh` 가 설치 시 0바이트 템플릿을 경고로 표면화하므로 인터뷰 시작 전에 확인한다.
118
119
 
119
120
  ## Notes
120
121
 
@@ -66,6 +66,23 @@ if [[ "${HEAD_SHA}" != "${UPSTREAM_SHA}" ]]; then
66
66
  exit 1
67
67
  fi
68
68
 
69
+ # 0바이트 템플릿 검증 (#660). 업스트림 파일이 빈 채 커밋돼 있으면 해당 경로의
70
+ # 문서 생성이 조용히 실패하므로 설치 시점에 경고로 표면화한다. 다른 경로의
71
+ # 문서는 정상 생성되므로 경고만 하고 설치는 계속한다.
72
+ EMPTY_TEMPLATES=()
73
+ while IFS= read -r -d '' EMPTY_FILE; do
74
+ EMPTY_TEMPLATES+=("${EMPTY_FILE#"${CLONE_DIR}/"}")
75
+ done < <(find "${CLONE_DIR}" -type f -name '*.tmpl' -size 0 -print0)
76
+
77
+ if (( ${#EMPTY_TEMPLATES[@]} > 0 )); then
78
+ echo "[korean-privacy-terms] warning: pinned upstream 에 0바이트 템플릿 ${#EMPTY_TEMPLATES[@]}건이 있다." >&2
79
+ for EMPTY_TEMPLATE in "${EMPTY_TEMPLATES[@]}"; do
80
+ echo " - ${EMPTY_TEMPLATE}" >&2
81
+ done
82
+ echo " 해당 템플릿 경로의 문서는 생성되지 않는다. 나머지 경로는 정상 생성된다." >&2
83
+ echo " 상세와 해소 상태는 스킬 instruction.md 의 Failure modes 를 확인해달라." >&2
84
+ fi
85
+
69
86
  # Dual-install targets. Both paths respect AGENTS.md indirection rules and do not
70
87
  # introduce repo-local skill directories.
71
88
  HOME_DIRS=(
@@ -0,0 +1,195 @@
1
+ # 종교시설 찾기 / Religious Facility Search
2
+
3
+ ## What this skill does
4
+
5
+ 동네·역명·랜드마크 텍스트를 기준점으로 잡고 주변 **종교시설을 거리순으로 조회**한다.
6
+ 교회, 성당, 사찰을 같은 경로로 다루며 기본 대상은 교회다.
7
+
8
+ - 조회 전용이다. 등록, 연락, 방문 예약 같은 액션은 하지 않는다.
9
+ - 위치는 사용자가 제공한 텍스트만 쓴다. 자동 위치 추적을 하지 않는다.
10
+ - **공식 Kakao Local REST API**를 `k-skill-proxy` 경유로 호출한다. 사용자 머신에는
11
+ API 키가 필요 없고, 프록시 운영자의 `KAKAO_REST_API_KEY`로 인증한다.
12
+
13
+ ## When to use
14
+
15
+ - "근처 교회 찾아줘"
16
+ - "강남역 근처 교회 알려줘"
17
+ - "명동 성당 위치가 어디야"
18
+ - "경주에 절 어디 있어?"
19
+ - "이 근처 종교시설 알려줘"
20
+ - "온누리교회 주소 알려줘"
21
+
22
+ ## Inputs
23
+
24
+ - `location`: 동네·역명·랜드마크. 예: `강남역`, `성수동`, `경주`
25
+ - `--name`: 시설 이름으로 직접 검색. 예: `--name 온누리교회`
26
+ - `--type`: `교회`(기본) / `성당` / `절` / `사찰` / `전체`
27
+ - `--radius`: 기준점으로부터 최대 거리(m, 최대 20000). Kakao Local API의 상한이다
28
+ - `--limit`: 표시 개수 (기본 5, 최대 45). 페이지당 15건씩 최대 3페이지를 가져온다
29
+ - `--json`: JSON 출력
30
+
31
+ 사용자가 "성당", "절", "사찰"을 명시하면 `--type`을 그에 맞게 지정한다. 종류를 특정하지
32
+ 않고 "종교시설"이라고만 하면 `--type 전체`를 쓴다.
33
+
34
+ 위치도 이름도 없으면 **먼저 사용자에게 위치를 묻는다.** 비대화형 자동화에서는
35
+ 임의로 좁히지 말고 "입력 없음"을 명시한다.
36
+
37
+ 권장 질문:
38
+
39
+ ```text
40
+ 어느 동네나 역 근처에서 찾을까요? (예: 강남역, 성수동)
41
+ ```
42
+
43
+ ## Public access path discovered
44
+
45
+ ### 선택한 공개 경로
46
+
47
+ 공식 Kakao Local REST API의 keyword 검색 하나만 쓴다. `k-skill-proxy`가 운영자 키로
48
+ 인증을 대행하므로 사용자 머신에는 키가 필요 없다.
49
+
50
+ 1. `GET {proxy}/v1/kakao-map/search/keyword?q=<위치>`
51
+ → 첫 번째 결과를 기준점(anchor) 좌표로 쓴다.
52
+ 2. `GET {proxy}/v1/kakao-map/search/keyword?q=<종류>&x=<lon>&y=<lat>&radius=<m>&sort=distance&size=15&page=1..3`
53
+ → 거리순 장소 목록. 거리는 upstream이 주는 공식 `distance` 필드를 그대로 쓴다.
54
+
55
+ `{proxy}`는 기본 `https://k-skill-proxy.nomadamas.org`이며 `KSKILL_PROXY_BASE_URL`로
56
+ 바꿀 수 있다. 같은 라우트를 `kakao-map` 스킬이 이미 쓰고 있다.
57
+
58
+ ### 왜 이 경로인가
59
+
60
+ - **공식 문서화 API**: Kakao Developers가 스키마를 보장한다. 이전의 모바일 웹 표면
61
+ (`m.map.kakao.com`) 스크래핑은 문서가 없어 구조가 바뀌면 깨지는 상수 리스크가 있었다.
62
+ - **proxy 편입 규칙 적합**: upstream이 API 키를 요구하므로 `AGENTS.md` 규칙상
63
+ `k-skill-proxy`를 거치는 것이 맞다. 프록시에 캐시·rate-limit이 있어 쿼터도 보호된다.
64
+ - **거리 계산 불필요**: `sort=distance` + `distance` 필드가 공식 제공되어 haversine을
65
+ 직접 돌릴 필요가 없다.
66
+ - **전국 커버리지**: 지자체 공공데이터(예: 서울 종로구 교회 현황
67
+ `https://www.data.go.kr/data/15117640/fileData.do`)는 구 단위로만 공개되어 전국
68
+ 표준데이터가 없다. 주 경로로 쓸 수 없어 참고 출처로만 둔다.
69
+ - **분류가 데이터에 있음**: `category_name`에 종교 분류가 그대로 들어온다.
70
+
71
+ ```
72
+ 교회 → 문화,예술 > 종교 > 기독교 > 교회
73
+ 성당 → 문화,예술 > 종교 > 천주교 > 성당
74
+ 사찰 → 문화,예술 > 종교 > 불교 > 절,사찰
75
+ 기타 → 문화,예술 > 종교 > 종교시설 / 문화,예술 > 종교 > 통일교 …
76
+ ```
77
+
78
+ `category_name`에 `종교` 노드가 있는 결과만 통과시키므로 `써브웨이 명동성당점` 같은
79
+ 이름만 비슷한 음식점·서점이 결과에 섞이지 않는다. `--type`은 그 안에서 마지막
80
+ 두 분류 토큰으로 한 번 더 좁힌다.
81
+
82
+ ### fallback 순서
83
+
84
+ 1. 기준점 해석 실패 → 거리 없이 결과 목록만 제공한다 (stderr에 사유 표시).
85
+ 2. 지정한 종류가 0건 → `--type 전체`로 넓히거나 `--radius`를 걸었다면 풀고 재시도한다.
86
+ 3. 검색 결과 0건 → 상위 행정구역으로 넓혀 재시도한다.
87
+ 4. 프록시 장애가 계속되면 결과를 지어내지 말고 실패를 그대로 알린다.
88
+
89
+ ## Workflow
90
+
91
+ 1. 사용자에게 위치와 시설 종류를 확인한다. 위치가 없으면 먼저 묻는다.
92
+ 2. helper를 실행한다.
93
+
94
+ ```bash
95
+ npx -y @nomadamas/k-skill@0 exec religious-facility-search scripts/religious_facility_search.py -- "강남역" --limit 5
96
+ ```
97
+
98
+ 성당:
99
+
100
+ ```bash
101
+ npx -y @nomadamas/k-skill@0 exec religious-facility-search scripts/religious_facility_search.py -- "명동" --type 성당 --limit 5
102
+ ```
103
+
104
+ 사찰:
105
+
106
+ ```bash
107
+ npx -y @nomadamas/k-skill@0 exec religious-facility-search scripts/religious_facility_search.py -- "경주" --type 절 --limit 5
108
+ ```
109
+
110
+ 종류를 가리지 않을 때:
111
+
112
+ ```bash
113
+ npx -y @nomadamas/k-skill@0 exec religious-facility-search scripts/religious_facility_search.py -- "성수동" --type 전체 --radius 600
114
+ ```
115
+
116
+ 이름으로 검색:
117
+
118
+ ```bash
119
+ npx -y @nomadamas/k-skill@0 exec religious-facility-search scripts/religious_facility_search.py -- --name "온누리교회"
120
+ ```
121
+
122
+ 3. 결과를 3~5개로 요약한다. 각 항목에 이름, 주소, 거리, 연락처를 붙인다.
123
+ 4. 예배·미사·법회 시간을 물으면 **추정하지 말고** 카카오맵 장소 페이지나 전화로 안내한다.
124
+
125
+ ## Output
126
+
127
+ 기준 위치와 검색어를 먼저 보여주고 거리순 목록을 출력한다.
128
+
129
+ ```
130
+ 기준 위치: 강남역 2호선 (37.49809, 127.02800)
131
+ 검색어: "교회" · 후보 43건 중 종교시설 40건 (가까운 5건 표시)
132
+
133
+ 1. 강남성서침례교회 (교회 · 기독교)
134
+ 서울 서초구 강남대로55길 9-11
135
+ 약 313m
136
+ 전화 02-3474-3609
137
+ 지도 https://place.map.kakao.com/85095074
138
+ ```
139
+
140
+ `--json`은 다음 구조로 출력한다.
141
+
142
+ ```json
143
+ {
144
+ "query": "교회",
145
+ "anchor": { "name": "강남역 2호선", "lat": 37.49809, "lon": 127.02800 },
146
+ "scanned": 43,
147
+ "matched": 40,
148
+ "items": [
149
+ {
150
+ "id": "85095074",
151
+ "name": "강남성서침례교회",
152
+ "category": "교회",
153
+ "denomination": "기독교",
154
+ "category_group": "종교",
155
+ "lat": 37.4964,
156
+ "lon": 127.0380,
157
+ "address": "서울 서초구 강남대로55길 9-11",
158
+ "phone": "02-3474-3609",
159
+ "homepage": null,
160
+ "place_url": "https://place.map.kakao.com/85095074",
161
+ "distance_m": 313
162
+ }
163
+ ]
164
+ }
165
+ ```
166
+
167
+ `homepage`는 Kakao Local API가 외부 홈페이지를 제공하지 않아 항상 `null`이다.
168
+ 대신 `place_url`(카카오맵 장소 페이지)로 안내한다.
169
+
170
+ ## Done when
171
+
172
+ - 기준 위치와 시설 종류를 확정하고 사용자에게 알렸다.
173
+ - `category_name`의 `종교` 노드로 검증된 후보를 거리순 3~5개로 정리했다.
174
+ - 각 후보에 주소와, 있으면 전화번호·카카오맵 링크를 붙였다.
175
+ - 예배·미사·법회 시간을 임의로 지어내지 않고 공식 출처로 넘겼다.
176
+
177
+ ## Failure modes
178
+
179
+ - **검색 결과 없음**: 검색어가 좁거나 오탈자다. 상위 행정구역으로 넓혀 재시도한다.
180
+ - **지정 종류 0건**: 후보는 잡혔지만 해당 종류가 없다. `--type 전체`로 넓히거나, `--radius`를 걸었다면 풀고 확인한다. `--radius`는 결과를 좁히는 옵션이라 늘려도 새 후보를 가져오지 않는다.
181
+ - **프록시 503 `upstream_not_configured`**: 프록시에 `KAKAO_REST_API_KEY`가 없다. 운영자에게 키 설정을 요청한다.
182
+ - **프록시 401/403 → 503 변환**: Kakao 키 revoke나 일일 쿼터 초과 신호다. 잠시 후 재시도하고 계속되면 운영자에게 알린다.
183
+ - **프록시 5xx / 타임아웃**: 프록시 또는 upstream 일시 장애. 재시도하고 계속 실패하면 그대로 보고한다.
184
+ - **좌표 입력**: v1은 `위도,경도` 입력을 지원하지 않는다. helper가 명시적으로 거부하고
185
+ 동네·역명을 요구한다.
186
+ - **시·군 단위 입력**: 기준점이 그 안의 특정 장소로 잡혀 절대 거리가 대략치가 된다.
187
+ 동·역 단위에서 가장 정확하다.
188
+ - **예배·미사·법회 시간**: 이 데이터에 없다. 제공하지 않는다.
189
+
190
+ ## Notes
191
+
192
+ - 공개 정보 조회 전용이다. 종교나 교단의 우열을 평가하거나 이단 여부를 판별하지 않는다.
193
+ - 특정 시설이나 교단을 권유하지 않는다. 거리와 공개 정보만 근거로 나열한다.
194
+ - 예배·미사·법회 시간, 성직자, 교단 소속은 이 데이터에 없다. 추정하지 않고 공식 출처로 넘긴다.
195
+ - 사용자가 제공한 위치 텍스트만 쓴다. 위치를 자동으로 알아내려 하지 않는다.
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """종교시설 찾기 — 공식 Kakao Local API (k-skill-proxy 경유).
3
+
4
+ 공개 접근 경로:
5
+ 1) GET {proxy}/v1/kakao-map/search/keyword?q=<위치> → 기준점 좌표 (anchor)
6
+ 2) GET {proxy}/v1/kakao-map/search/keyword?q=<종류>&x=..&y=..&radius=..&sort=distance
7
+ → 거리순 장소 목록 (공식 distance 필드 사용)
8
+
9
+ upstream이 Kakao Developers REST API 키를 요구하므로 k-skill-proxy를 거친다
10
+ (사용자 머신에는 키가 필요 없고, 프록시 운영자 키로 인증한다).
11
+ category_name에 "종교" 노드가 있는 결과만 통과시키므로
12
+ 이름에 "교회"가 들어간 카페·서점 같은 오탐이 걸러진다.
13
+ """
14
+
15
+ import argparse
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+
24
+ DEFAULT_PROXY_BASE = "https://k-skill-proxy.nomadamas.org"
25
+ SEARCH_KEYWORD_PATH = "/v1/kakao-map/search/keyword"
26
+
27
+ USER_AGENT = "k-skill-religious-facility-search/1"
28
+
29
+ RELIGION_CATEGORY = "종교"
30
+ TYPE_ALIASES = {
31
+ "교회": ["교회"],
32
+ "성당": ["성당"],
33
+ "절": ["절", "사찰"],
34
+ "사찰": ["절", "사찰"],
35
+ "전체": [],
36
+ }
37
+ # --type 전체: "종교시설" 단일 키워드는 교회·사찰을 놓치므로 종류별로 합친다
38
+ ALL_TYPE_QUERIES = ["교회", "성당", "사찰"]
39
+ COORD_RE = re.compile(r"^\s*(-?\d+\.\d+)\s*,\s*(-?\d+\.\d+)\s*$")
40
+ PAGE_SIZE = 15 # Kakao Local keyword 검색의 페이지당 최대 건수
41
+ MAX_PAGES = 3 # 페이지네이션 상한 → 최대 45개 후보
42
+ MAX_CANDIDATES = PAGE_SIZE * MAX_PAGES
43
+
44
+
45
+ class LookupError_(Exception):
46
+ """조회 실패를 명시적 실패 모드로 올린다."""
47
+
48
+
49
+ def proxy_base():
50
+ return (os.environ.get("KSKILL_PROXY_BASE_URL") or DEFAULT_PROXY_BASE).rstrip("/")
51
+
52
+
53
+ def fetch_json(url, timeout=15):
54
+ request = urllib.request.Request(url, headers={"user-agent": USER_AGENT, "accept": "application/json"})
55
+ try:
56
+ with urllib.request.urlopen(request, timeout=timeout) as response:
57
+ raw = response.read().decode("utf-8", errors="replace")
58
+ except urllib.error.HTTPError as error:
59
+ detail = ""
60
+ try:
61
+ detail = error.read().decode("utf-8", errors="replace")[:200]
62
+ except Exception:
63
+ pass
64
+ raise LookupError_(f"프록시 HTTP {error.code} ({detail or '응답 본문 없음'})") from error
65
+ except urllib.error.URLError as error:
66
+ raise LookupError_(f"네트워크 오류: {error.reason}") from error
67
+
68
+ try:
69
+ return json.loads(raw)
70
+ except json.JSONDecodeError as error:
71
+ raise LookupError_("프록시 응답이 JSON이 아니다 (proxy 변경 가능)") from error
72
+
73
+
74
+ def search_places(query, x=None, y=None, radius=None, max_pages=MAX_PAGES):
75
+ """proxy keyword 검색. (documents, meta)를 돌려준다. 첫 페이지가 비면 명시적 실패."""
76
+ params = {"q": query, "size": PAGE_SIZE}
77
+ if x is not None and y is not None:
78
+ params.update({"x": x, "y": y, "sort": "distance"})
79
+ if radius:
80
+ params["radius"] = radius
81
+
82
+ documents, meta = [], {}
83
+ for page in range(1, max_pages + 1):
84
+ params["page"] = page
85
+ url = f"{proxy_base()}{SEARCH_KEYWORD_PATH}?{urllib.parse.urlencode(params)}"
86
+ payload = fetch_json(url)
87
+ batch = payload.get("documents") or []
88
+ meta = payload.get("meta") or {}
89
+ documents.extend(batch)
90
+ if page == 1 and not documents:
91
+ raise LookupError_(f'"{query}" 검색 결과가 비었다 (검색어를 넓히거나 지역명을 붙여볼 것)')
92
+ if meta.get("is_end", True) or not batch:
93
+ break
94
+ return documents, meta
95
+
96
+
97
+ def map_place(document):
98
+ parts = [p.strip() for p in (document.get("category_name") or "").split(">") if p.strip()]
99
+ distance_raw = (document.get("distance") or "").strip()
100
+ return {
101
+ "id": document.get("id"),
102
+ "name": document.get("place_name"),
103
+ "category": parts[-1] if parts else None,
104
+ "denomination": parts[-2] if len(parts) >= 4 else None,
105
+ "category_group": RELIGION_CATEGORY if RELIGION_CATEGORY in parts else None,
106
+ "lat": float(document["y"]) if document.get("y") else None,
107
+ "lon": float(document["x"]) if document.get("x") else None,
108
+ "address": (document.get("road_address_name") or document.get("address_name") or "").strip() or None,
109
+ "jibun": (document.get("address_name") or "").strip() or None,
110
+ "phone": (document.get("phone") or "").strip() or None,
111
+ "homepage": None, # Kakao Local API는 외부 홈페이지를 제공하지 않는다
112
+ "place_url": document.get("place_url"),
113
+ "distance_m": int(distance_raw) if distance_raw else None,
114
+ }
115
+
116
+
117
+ def resolve_anchor(location):
118
+ """위치 문자열을 좌표로 바꾼다. 실패하면 None (거리 없이 결과만 제공)."""
119
+ try:
120
+ documents, _ = search_places(location)
121
+ except LookupError_:
122
+ return None
123
+ if not documents:
124
+ return None
125
+ top = documents[0]
126
+ return {"name": top.get("place_name"), "lat": float(top["y"]), "lon": float(top["x"])}
127
+
128
+
129
+ def _category_tokens(place):
130
+ tokens = []
131
+ for part in (place.get("category"), place.get("denomination")):
132
+ if not part:
133
+ continue
134
+ tokens.extend(token.strip() for token in part.replace(",", " ").split() if token.strip())
135
+ return tokens
136
+
137
+
138
+ def matches_type(place, facility_type):
139
+ if place.get("category_group") != RELIGION_CATEGORY:
140
+ return False
141
+ wanted = TYPE_ALIASES.get(facility_type, [facility_type])
142
+ if not wanted:
143
+ return True
144
+ tokens = _category_tokens(place)
145
+ return any(token in tokens for token in wanted)
146
+
147
+
148
+ def _dedupe_by_id(places):
149
+ seen, unique = set(), []
150
+ for place in places:
151
+ if place["id"] not in seen:
152
+ seen.add(place["id"])
153
+ unique.append(place)
154
+ return unique
155
+
156
+
157
+ def collect(query, facility_type, anchor, radius_m, limit):
158
+ x = y = None
159
+ if anchor:
160
+ x, y = anchor["lon"], anchor["lat"]
161
+
162
+ if facility_type == "전체" and query == "전체":
163
+ # "종교시설" 단일 검색은 교회·사찰을 놓치므로 종류별 검색을 합친다
164
+ documents, scanned, hits = [], 0, 0
165
+ for token in ALL_TYPE_QUERIES:
166
+ try:
167
+ docs, _ = search_places(token, x=x, y=y, radius=radius_m)
168
+ except LookupError_:
169
+ continue # 한 종류가 0건이어도 나머지 종류는 돌려준다
170
+ hits += 1
171
+ scanned += len(docs)
172
+ documents.extend(docs)
173
+ if hits == 0:
174
+ raise LookupError_('"전체" 검색 결과가 비었다 (지역을 넓히거나 --type을 좁혀볼 것)')
175
+ else:
176
+ documents, _ = search_places(query, x=x, y=y, radius=radius_m)
177
+ scanned = len(documents)
178
+
179
+ places = _dedupe_by_id(map_place(doc) for doc in documents)
180
+ places = [p for p in places if matches_type(p, facility_type)]
181
+
182
+ if anchor:
183
+ places.sort(key=lambda p: (p["distance_m"] is None, p["distance_m"] or 0))
184
+
185
+ return scanned, len(places), places[:limit]
186
+
187
+
188
+ def format_report(query, anchor, scanned, matched, places, facility_type):
189
+ lines = []
190
+ if anchor:
191
+ lines.append(f"기준 위치: {anchor['name']} ({anchor['lat']:.5f}, {anchor['lon']:.5f})")
192
+ summary = f'검색어: "{query}" · 후보 {scanned}건 중 종교시설 {matched}건'
193
+ if matched > len(places):
194
+ summary += f" (가까운 {len(places)}건 표시)"
195
+ lines.append(summary)
196
+ lines.append("")
197
+
198
+ if not places:
199
+ lines.append(f"조건에 맞는 {facility_type}을(를) 찾지 못했다. 지역을 넓혀 다시 찾거나 "
200
+ f"--type 전체 로 확인할 것 (--radius 는 결과를 좁히는 옵션이다).")
201
+ return "\n".join(lines)
202
+
203
+ for index, place in enumerate(places, start=1):
204
+ label = place["category"] or "종교시설"
205
+ if place.get("denomination"):
206
+ label = f"{label} · {place['denomination']}"
207
+ lines.append(f"{index}. {place['name']} ({label})")
208
+ if place.get("address"):
209
+ lines.append(f" {place['address']}")
210
+ if place.get("distance_m") is not None:
211
+ lines.append(f" 약 {place['distance_m']:,}m")
212
+ contact = []
213
+ if place.get("phone"):
214
+ contact.append(f"전화 {place['phone']}")
215
+ if place.get("homepage"):
216
+ contact.append(place["homepage"])
217
+ if contact:
218
+ lines.append(" " + " ".join(contact))
219
+ lines.append(f" 지도 {place['place_url']}")
220
+ lines.append("")
221
+
222
+ lines.append("예배·미사·법회 시간은 이 데이터에 없다. 각 홈페이지나 전화로 확인할 것.")
223
+ return "\n".join(lines).rstrip()
224
+
225
+
226
+ def main():
227
+ parser = argparse.ArgumentParser(description="종교시설 찾기 (공식 Kakao Local API, k-skill-proxy 경유)")
228
+ parser.add_argument("location", nargs="?", help="동네·역명·랜드마크 (예: 강남역, 성수동)")
229
+ parser.add_argument("--name", help="교회/시설 이름으로 직접 검색")
230
+ parser.add_argument("--type", default="교회", choices=sorted(TYPE_ALIASES), help="시설 종류 (기본: 교회)")
231
+ parser.add_argument("--radius", type=int, help="기준 위치로부터 최대 거리(m, 최대 20000)")
232
+ parser.add_argument("--limit", type=int, default=5, help=f"표시 개수 (기본 5, 최대 {MAX_CANDIDATES})")
233
+ parser.add_argument("--json", action="store_true", help="JSON으로 출력")
234
+ args = parser.parse_args()
235
+
236
+ if not args.location and not args.name:
237
+ parser.error("location 또는 --name 중 하나는 필요하다. 사용자에게 현재 위치를 먼저 물어볼 것.")
238
+
239
+ if not 1 <= args.limit <= MAX_CANDIDATES:
240
+ parser.error(f"--limit은 1~{MAX_CANDIDATES} 사이여야 한다. 페이지 {MAX_PAGES}장(페이지당 "
241
+ f"{PAGE_SIZE}건)까지만 가져오므로 그보다 많이 표시할 수 없다.")
242
+
243
+ if args.radius is not None and not 1 <= args.radius <= 20000:
244
+ parser.error("--radius는 1~20000(m) 사이여야 한다. Kakao Local API의 상한이다.")
245
+
246
+ if args.location and COORD_RE.match(args.location):
247
+ parser.error("좌표 검색은 지원하지 않는다. 동네·역명·랜드마크로 입력할 것 (예: 강남역, 성수동).")
248
+
249
+ if args.name:
250
+ # 위치는 좌표(x,y)로 전달하므로 검색어는 시설 이름만 쓴다
251
+ # ("강남역 온누리교회"처럼 붙이면 keyword API가 0건을 돌려준다)
252
+ query = args.name
253
+ elif args.type == "전체":
254
+ query = args.type # collect()가 종류별 검색으로 풀어서 합친다
255
+ else:
256
+ query = args.type
257
+
258
+ try:
259
+ anchor = resolve_anchor(args.location) if args.location else None
260
+ if args.location and anchor is None:
261
+ print(f"기준 위치 \"{args.location}\"을(를) 찾지 못해 거리 없이 조회한다.", file=sys.stderr)
262
+ scanned, matched, places = collect(query, args.type, anchor, args.radius, args.limit)
263
+ except LookupError_ as error:
264
+ print(f"조회 실패: {error}", file=sys.stderr)
265
+ return 1
266
+
267
+ if args.json:
268
+ print(json.dumps({"query": query, "anchor": anchor, "scanned": scanned,
269
+ "matched": matched, "items": places},
270
+ ensure_ascii=False, indent=2))
271
+ else:
272
+ print(format_report(query, anchor, scanned, matched, places, args.type))
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "religious-facility-search",
3
+ "description": "Use when the user asks 근처 교회 찾기, 성당 위치, 절·사찰 찾기, 종교시설 조회, or church/cathedral/temple lookup by Korean neighborhood, station, or landmark. 공식 Kakao Local API를 k-skill-proxy 경유로 호출해 종교시설을 거리순으로 조회하고 주소·전화·카카오맵 링크를 정리한다. 사용자 API 키가 필요 없는 조회 전용 스킬이다.",
4
+ "profiles": [
5
+ "proxy",
6
+ "lookup"
7
+ ],
8
+ "frontmatter": "name: religious-facility-search\ndescription: Use when the user asks 근처 교회 찾기, 성당 위치, 절·사찰 찾기, 종교시설 조회, or church/cathedral/temple lookup by Korean neighborhood, station, or landmark. 공식 Kakao Local API를 k-skill-proxy 경유로 호출해 종교시설을 거리순으로 조회하고 주소·전화·카카오맵 링크를 정리한다. 사용자 API 키가 필요 없는 조회 전용 스킬이다.\nlicense: MIT\nmetadata:\n category: religion\n locale: ko-KR\n phase: v1"
9
+ }