@nomadamas/k-skill 0.7.0 → 0.9.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.
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ # 공유누리 공공시설 검색
2
+
3
+ ## What this skill does
4
+
5
+ `sharenuri-facility-search`는 행정안전부 **공유누리**(공공개방자원 포털, `https://www.eshare.go.kr`)의 로그인 없이 열려 있는 통합검색 표면을 호출해 전국 공공개방자원을 조회한다.
6
+
7
+ - 대상: 공유누리에 등록된 공공개방자원 전체 — 시설·공간(대관), 물품(대여), 연구·실험장비, 교육·강좌, 공연·전시·행사 (5개 대분류, 2026-09-12 실측)
8
+ - 검색 조건: 키워드, 시·도, 시군구, 자원 대분류, 무료 여부, 인터넷 예약 가능 여부
9
+ - 상세 조회: 자원분류, 명칭, 장소, 제공기관, 담당자, 예약방법, 예약문의, 이용대상, 이용정원, 심사여부, 이용요금 등 기본정보 표
10
+
11
+ 조회 전용이다. 공유누리 로그인, 예약 신청, 결제, 신청서 제출은 자동화하지 않는다. 예약은 상세 URL의 공식 화면에서 사용자가 직접 진행하도록 안내한다.
12
+
13
+ ## Public access path discovered
14
+
15
+ ### Primary source: 공유누리 통합검색 (인증키·로그인 불필요)
16
+
17
+ 2026-09-12 curl 실측으로 확인한 공개 read-only 경로다. 모두 익명 세션 쿠키만으로 동작했다.
18
+
19
+ | 용도 | 경로 | 비고 |
20
+ | --- | --- | --- |
21
+ | 세션 시작 | `GET /UserPortal/adv/unifdSearch/UnifiedSearchMainView.do` | 검색 POST 전 1회 호출해 쿠키 확보 |
22
+ | 자원 검색 | `POST /UserPortal/adv/unifdSearch/ConditionsSearch.do` | HTML fragment 반환, `collection=resource_cate1`이 자원정보 |
23
+ | 시군구 코드 | `POST /UserPortal/comm/zip/selectsigunguList.do` (`addr=<시도코드>`) | JSON 반환 |
24
+ | 대분류 코드 | `POST /UserPortal/Upm/searchUserPortalRsrcCls.do` (`rsrcClsCd=1`) | JSON 반환 |
25
+ | 상세 페이지 | `GET /UserPortal/<Upv 경로>/index.do?rsrc_no=<번호>&rsrc_dcd=<코드>` | 로그인 없이 기본정보 표 열람 가능 |
26
+
27
+ 검색 파라미터(통합검색 화면 JS `getterDetailParameters()` 기준): `searchWrd`, `ctrd`(시·도 코드), `sigg`(시군구 코드), `mj_rsrc_cls_cd`(대분류), `free_yn=Y`(무료만), `intnet_rsrv_psbl_yn=Y`(인터넷 예약 가능만), `sort=RANK`, `order=DESC`, `pageIndex`(1부터), `viewType=UnitPage`.
28
+
29
+ 검색 결과의 `rsrcDcd` 7~9번째 자리(예: `CPS001.002` → `002`)로 상세 페이지 경로를 고른다 (화면 JS `fnNewPageResourceDetail` 기준): `001`→`Upv/UprResrcGym`, `002`/`098`→`Upv/UprResrcFacl`, `003`→`Upv/UprResrcCamp`, `004`→`Upv/UprResrcEqp`, `005`→`Upv/UprResrcGds`, `006`→`Upv/UprResrcEdu`, `007`→`Upv/UprResrcExpr`, `008`→`Upv/UprResrcPfm`, `009`→`Upv/UprResrcEvnt`.
30
+
31
+ 실측 근거 (2026-09-12):
32
+
33
+ - `searchWrd=회의실` → 총 5,828건, 첫 페이지 10건 반환
34
+ - `searchWrd=회의실&ctrd=11&sigg=680&free_yn=Y` (서울 강남구 무료, 화면 `gunguCd`) → 총 3건
35
+ - 존재하지 않는 검색어 → `총 0건` + "목록이 없는" 마크업
36
+ - 상세 `rsrc_no=FF20O3200420` → 200, 기본정보 표(자원분류~이용요금) 확인
37
+
38
+ ### Fallback / 공식 대안 (인증키 필요, 이 스킬에서는 사용하지 않음)
39
+
40
+ 같은 데이터를 공식 키 기반 API로도 받을 수 있다. 레포 정책상 "인증 없이 동작하는 공개 read-only endpoint"가 있으므로 프록시 route는 추가하지 않았다.
41
+
42
+ - 공유누리 OPEN API (`https://www.eshare.go.kr/OpenApi/Info/index.do`): 담당자 승인제 인증키 발급
43
+ - 공공데이터포털: `행정안전부_공공개방자원 물품 목록`(data.go.kr 15077523), `행정안전부_공공개방자원 문화숙박시설 목록`(15077518), `행정안전부_공공개방자원 관리기관 정보`(15076186) 등 — 활용신청 후 서비스키 필요
44
+
45
+ ## When to use
46
+
47
+ - "우리 동네 무료 회의실 예약 가능한 곳 찾아줘"
48
+ - "서울 강남구에서 대관 가능한 공공 강의실 조회해줘"
49
+ - "공유누리에서 캠핑장/체육관/주차장 검색해줘"
50
+ - "이 시설 예약 문의 전화번호랑 요금 알려줘"
51
+
52
+ ## When not to use
53
+
54
+ - 예약 신청·결제·신청서 제출까지 자동화하려는 경우 (공유누리 로그인 영역, 하지 않음)
55
+ - 공유누리에 등록되지 않은 시설 (서울 공공서비스예약 등 지자체 자체 예약 시스템만 있는 시설은 안 잡힐 수 있음)
56
+ - 실시간 잔여 좌석/시간대별 예약 현황이 필요한 경우 (이 스킬은 자원 정보와 예약 경로만 다룬다)
57
+
58
+ ## Prerequisites
59
+
60
+ - 인터넷 연결, `python3` (표준 라이브러리만 사용)
61
+ - 시크릿·API 키·로그인 불필요
62
+
63
+ ## Inputs
64
+
65
+ - `search` 하위명령:
66
+ - `--query`, `-q`: 검색어 (필수)
67
+ - `--sido`: 시·도 이름 (예: `서울특별시`) 또는 `--ctrd`: 시·도 코드 (예: `11`)
68
+ - `--sigg`: 시군구 코드 (`sigungu` 하위명령의 `gunguCd`, 예: 강남구 `680`; 법정동 5자리 `11680`도 허용)
69
+ - `--major-code`: 자원 대분류 코드 (`categories` 하위명령으로 조회, 예: 시설·공간(대관) `010000`)
70
+ - `--free`: 무료 자원만, `--reservable`: 인터넷 예약 가능 자원만
71
+ - `--page`: 시작 페이지 (기본 1), `--limit`: 최대 건수 (기본 10, 최대 50)
72
+ - `detail` 하위명령: `--rsrc-no`, `--rsrc-dcd` (검색 결과 항목의 값)
73
+ - `sigungu` 하위명령: `--ctrd` 시·도 코드
74
+ - `categories` 하위명령: 인자 없음
75
+ - 공통: `--json` 지정 시 JSON 출력 (기본은 사람용 텍스트)
76
+
77
+ ## Workflow
78
+
79
+ ### 1. 키워드로 검색
80
+
81
+ ```bash
82
+ npx -y @nomadamas/k-skill@0 exec sharenuri-facility-search scripts/sharenuri_search.py -- \
83
+ search --query 회의실 --limit 10
84
+ ```
85
+
86
+ ### 2. 지역·조건으로 좁히기
87
+
88
+ 시군구 코드가 필요하면 먼저 조회한다.
89
+
90
+ ```bash
91
+ npx -y @nomadamas/k-skill@0 exec sharenuri-facility-search scripts/sharenuri_search.py -- \
92
+ sigungu --ctrd 11
93
+
94
+ npx -y @nomadamas/k-skill@0 exec sharenuri-facility-search scripts/sharenuri_search.py -- \
95
+ --json search --query 회의실 --sido 서울특별시 --sigg 680 --free
96
+ ```
97
+
98
+ ### 3. 상세정보와 예약 경로 확인
99
+
100
+ ```bash
101
+ npx -y @nomadamas/k-skill@0 exec sharenuri-facility-search scripts/sharenuri_search.py -- \
102
+ detail --rsrc-no FF20O3200420 --rsrc-dcd CPS001.002
103
+ ```
104
+
105
+ 답변할 때는 결과가 나온 공식 출처(공유누리 상세 URL)를 함께 보여준다. 예약을 원하면 해당 상세 URL의 공식 화면에서 사용자가 직접 로그인·신청하도록 안내하고, 대신 예약하지 않는다.
106
+
107
+ ## Done when
108
+
109
+ - 통합검색 `ConditionsSearch.do`를 호출해 총 건수와 항목(명칭·분류·위치·요금·상세 URL)을 얻었다.
110
+ - 요청한 필터(지역·무료·예약가능·대분류)가 요청 파라미터에 반영됐다.
111
+ - 상세 요청 시 기본정보 표의 예약방법·문의처·요금을 공식 상세 URL과 함께 전달했다.
112
+ - 로그인, 예약 신청, 결제, 신청서 제출은 실행하지 않았다.
113
+
114
+ ## Failure modes
115
+
116
+ - `총 0건`: 조건에 맞는 자원이 없음. 검색어를 넓히거나 필터를 해제해 재시도한다.
117
+ - 시군구/대분류 응답이 JSON이 아님: 공유누리 화면 구조 변경 가능성. 공유누리 통합검색 화면(`https://www.eshare.go.kr/UserPortal/adv/unifdSearch/UnifiedSearchMainView.do`)을 브라우저로 직접 안내한다.
118
+ - 상세 페이지에서 기본정보를 찾지 못함: `rsrc_no`/`rsrc_dcd`가 오래됐거나 화면 구조 변경. 검색을 다시 실행해 최신 식별자를 얻는다.
119
+ - 연결 실패/타임아웃: 공유누리 서버 점검 또는 네트워크 문제. 잠시 후 재시도하며, 반복 호출로 우회하지 않는다.
120
+ - 공유누리 미등록 시설: 결과에 없다고 시설이 존재하지 않는 것은 아니다. 지자체 자체 예약 사이트(예: 서울 공공서비스예약)를 별도로 안내한다.
121
+
122
+ ## Official surfaces
123
+
124
+ - 공유누리 포털: <https://www.eshare.go.kr>
125
+ - 통합검색 화면: <https://www.eshare.go.kr/UserPortal/adv/unifdSearch/UnifiedSearchMainView.do>
126
+ - 공유누리 OPEN API 안내: <https://www.eshare.go.kr/OpenApi/Info/index.do>
127
+ - 공공데이터포털 공유누리 데이터셋: <https://www.data.go.kr/data/15077523/openapi.do>, <https://www.data.go.kr/data/15077518/openapi.do>, <https://www.data.go.kr/data/15076186/openapi.do>
@@ -0,0 +1,23 @@
1
+ # DISCLAIMER — `sharenuri-facility-search`
2
+
3
+ ## 비제휴 및 상표 사용
4
+
5
+ 이 스킬은 이 문서에서 언급하는 상표권자나 서비스 운영사의 공식 기능 또는 공식 지원 도구가 아닙니다. 해당 권리자·운영사와 공식 제휴, 후원, 승인, 인증 또는 협업한 사실이 전혀 없습니다. 제3자 상표와 서비스명은 스킬의 기능, 조회 대상 또는 호환 대상을 정확히 설명하기 위해 필요한 범위에서만 사용하며, 각 상표의 권리는 해당 권리자에게 있습니다.
6
+
7
+ 대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 타인의 표장을 자기 상품의 출처표시가 아니라 상품의 기능 또는 적용 기종을 설명하기 위해 사용하고, 실제 표시 태양도 상표 사용으로 인식될 수 없는 경우에는 상표권 침해가 아니라고 판시했습니다. 다만 이는 제3자 상표 사용을 포괄적으로 허용한다는 뜻이 아니며, [상표법 제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조)와 실제 거래계의 인식에 따라 출처 혼동 여부를 개별적으로 판단해야 합니다.
8
+
9
+ ## 공개정보에 대한 자동 접근
10
+
11
+ 대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)(야놀자 정보 수집 사건)은 서비스 제공자가 접근권한을 제한했는지를 보호조치·이용약관 등 객관적으로 드러난 사정을 종합해 판단해야 한다고 판시했습니다. 그 사건에서는 별도 보호조치 없이 일반 이용자가 접근할 수 있었고 이미 공개된 정보를 수집한 사정 등에 비추어 정보통신망 침입을 인정하지 않았습니다. 따라서 공개되어 누구나 얻을 수 있는 정보를 기계적으로 수집했다는 사정만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아닙니다.
12
+
13
+ 그러나 위 판결은 크롤링을 일반적으로 허용한 판결이 아닙니다. 접근통제나 명시적 제한을 우회하는 행위, 데이터베이스의 전부·상당한 부분을 복제하거나 반복적·체계적으로 수집하여 같은 결과를 내는 행위는 별도로 판단됩니다. 특히 [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)는 데이터베이스제작자의 권리를 보호하며, 정보처리에 현실적 장애를 일으켜 영업을 방해하면 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)에 따른 책임이 문제될 수 있습니다.
14
+
15
+ ## 필수 사용 제한
16
+
17
+ 1. 공개정보 자동 수집 기능은 반드시 개인의 정보 조회용으로만 사용합니다.
18
+ 2. 조직적·체계적·대량 크롤링, 별도 데이터베이스 구축, 재판매 또는 원본 콘텐츠 재배포에 사용하지 않습니다.
19
+ 3. 특정 브랜드나 업체의 정상적인 영업·서비스를 방해하지 않도록 요청 횟수와 범위를 최소화하며, 공격적 polling이나 병렬 대량 호출을 하지 않습니다.
20
+ 4. 로그인·유료벽·CAPTCHA·접근통제·rate limit·IP 차단을 우회하거나 차단 회피를 위해 계정·IP·헤더를 변경하지 않습니다. 접근 거부나 차단 신호가 확인되면 즉시 중단합니다.
21
+ 5. 적용되는 이용약관, robots 지침, API 조건, 저작권·데이터베이스권·개인정보 보호 의무를 별도로 준수합니다.
22
+
23
+ 이 문서는 일반적인 위험 경계를 알리기 위한 것이며, 개별 행위의 적법성을 보증하거나 법률 자문을 제공하지 않습니다.
@@ -0,0 +1,9 @@
1
+ # 상표 사용 법적 고지 — `sharenuri-facility-search`
2
+
3
+ 이 스킬에서 `공유누리` 명칭은 행정안전부 공공개방자원 포털의 공공시설·공공개방자원 정보 조회의 **대상 공식 서비스**를 식별하기 위해 사용한다. 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조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
6
+
7
+ 따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 저작권, 데이터베이스권, 부정경쟁방지법, 약관·계약, 개인정보 또는 접근 방식의 적법성을 판단하지 않는다.
8
+
9
+ 전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)