@nomadamas/k-skill 0.8.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.
package/bin/k-skill.js CHANGED
@@ -10,6 +10,7 @@ const {
10
10
  } = require("../src/assemble");
11
11
  const { runBundledScript } = require("../src/execute");
12
12
  const { detectRuntime } = require("../src/detect");
13
+ const { version } = require("../package.json");
13
14
 
14
15
  function usage() {
15
16
  return [
@@ -23,6 +24,11 @@ function usage() {
23
24
  " path <skill> <file> Print the absolute path of a bundled asset",
24
25
  " files <skill> Print local paths of the skill's bundled helper files",
25
26
  " list List bundled skills",
27
+ " version Print the installed CLI version",
28
+ "",
29
+ "Options:",
30
+ " -h, --help Show this help",
31
+ " -v, -V, --version Print the installed CLI version",
26
32
  "",
27
33
  "Runtime detection: DOLSHOI_ACTION_BROKER_URL enables Dolshoi mode;",
28
34
  "CLOAKBROWSER_PEEK_TOKEN marks CloakBrowser availability.",
@@ -37,6 +43,11 @@ function main() {
37
43
  return 0;
38
44
  }
39
45
 
46
+ if (command === "version" || command === "--version" || command === "-V" || command === "-v") {
47
+ console.log(version);
48
+ return 0;
49
+ }
50
+
40
51
  if (command === "list") {
41
52
  for (const name of listSkills()) console.log(name);
42
53
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -4,7 +4,7 @@
4
4
 
5
5
  한국에서 맛집, 음식점, 식당, 밥집, 카페, 고깃집, 회식 장소를 찾을 때 쓴다. 사용자가 말한 장소를 먼저 `kakao-map`으로 정확한 도로명주소로 해석한 뒤, 그 주소를 중심으로 BC카드 eat.pl 잇플의 가맹점 검색을 조회한다. 결과는 반환 매출 랭킹 상위 순서를 보존하며, 사용자의 장르·동네·외국인 방문·회식·영업시간·혼잡 시간 조건으로 좁혀 표로 제시한다.
6
6
 
7
- 추천은 별점 순위가 아니라 최근 1개월 BC카드 결제 데이터 기반 매출 순이다. 매출액(원)은 없고, 대신 `allSaleRnk`/`ccgSaleRnk` 백분위로 **얼마나 상위인지**를 반드시 숫자로 보여 준다. `srtTime`/`endTime`은 **영업 예상 시간**으로 정리한다. 모든 행에 eat.pl 상세 링크를 첨부한다.
7
+ 추천은 별점 순위가 아니라 최근 1개월 BC카드 결제 데이터 기반 매출 순이다. 매출액(원)은 없고, 대신 `allSaleRnk`/`ccgSaleRnk` 백분위로 **얼마나 상위인지**를 반드시 숫자로 보여 준다. `srtTime`/`endTime`은 **영업 예상 시간**으로 정리한다. 모든 행은 가맹점명 바로 오른쪽에 eat.pl 상세 링크를 붙이고, 전국·지역 매출 상위 바로 오른쪽에 카카오맵 평점을 함께 보여 준다.
8
8
 
9
9
  ## 사용자 답변 규칙 (필수)
10
10
 
@@ -16,7 +16,7 @@
16
16
  - 내부 필드명 (`location`, `merTpbuzNm`, `allSaleRnk`, `ccgSaleRnk`, `merUrl`, `frnrVsitYn`, `empyVsitYn`, `srtTime` 등)
17
17
  - `k-skill-proxy`, 릴레이, Lightsail, gpu01, API, JSON, 캐시, 좌표 체계
18
18
 
19
- 표의 전국·지역 매출 상위 %, 영업 예상 시간, `[eat.pl 상세](url)`, 출처 한 줄은 출력 계약대로 유지한다. 실패해도 오류 코드나 엔드포인트를 말하지 말고, 그 조건으로는 매장을 찾지 못했다고만 안내한다.
19
+ 표의 전국·지역 매출 상위 %, 카카오맵 평점, 영업 예상 시간, `[eat.pl 상세](url)`, 출처 한 줄은 출력 계약대로 유지한다. 실패해도 오류 코드나 엔드포인트를 말하지 말고, 그 조건으로는 매장을 찾지 못했다고만 안내한다.
20
20
 
21
21
  ## When to use
22
22
 
@@ -34,7 +34,7 @@
34
34
 
35
35
  아래 절차는 에이전트 내부 작업이다. 사용자 답변에 그대로 옮기지 않는다.
36
36
 
37
- 1. 장소명이 모호하면 먼저 행정구역 또는 도로명주소를 확인한다. `kakao-map` 스킬을 우선 사용한다. 이 경로는 k-skill-proxy가 중계하는 **카카오 공식 Local REST API** (`GET /v1/kakao-map/search/keyword`, `coord2address` 등)이다. 카카오맵 웹 페이지를 스크래핑하지 않는다. 사용자가 지정한 경우 다른 지도 스킬로 교차 확인할 수 있다.
37
+ 1. 장소명이 모호하면 먼저 행정구역 또는 도로명주소를 확인한다. `kakao-map` 스킬을 우선 사용한다. 이 경로는 k-skill-proxy가 중계하는 **카카오 공식 Local REST API** (`GET /v1/kakao-map/search/keyword`, `coord2address` 등)이다. 장소 해석에 카카오맵 웹 페이지를 스크래핑하지 않는다. 카카오맵 평점만 아래 5번 단계의 번들 헬퍼가 공개 검색 페이지에 표시된 값을 읽는다. 사용자가 지정한 경우 다른 지도 스킬로 교차 확인할 수 있다.
38
38
  2. 검색 중심은 장소의 정확한 주소에서 최소한 시·군·구와 도로명/동 단위로 정한다. eat.pl API는 텍스트 주소 부분일치 검색이며 반경(meter) 검색 API가 아니므로, API 결과를 실제 근처로 단정하지 말고 지도 스킬로 위치를 교차 확인한다.
39
39
  3. 다음 프록시 endpoint를 GET으로 호출한다.
40
40
 
@@ -51,12 +51,23 @@ curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v
51
51
  - `merNm`, `merchant_name`: 가맹점명 일부
52
52
 
53
53
  4. 최대 100건을 받은 뒤 지도 검색 결과와 주소·상호를 대조하고, 요청 조건에 맞는 매장만 남긴다. 존재하지 않는 리뷰·메뉴·주차·영업 여부를 추측하지 않는다.
54
- 5. 아래 **출력 계약**을 만족하는 표만 사용자에게 보여 준다. 계약을 빠뜨린 표는 미완성이다.
55
- 6. 반드시 `eat.pl 잇플 · BC카드 결제 데이터 기반`을 출처로 표기한다.
54
+ 5. 표에 넣을 각 매장의 카카오맵 평점을 번들 헬퍼로 조회한다. 가맹점명과 주소는 eat.pl 응답값을 그대로 넘긴다.
55
+
56
+ ```bash
57
+ npx -y @nomadamas/k-skill@0 exec bccard-eatpl-search scripts/kakao-place-rating.mjs -- \
58
+ --name '{merNm}' --address '{addr}'
59
+ ```
60
+
61
+ - 출력 JSON의 `status`가 `ok`일 때만 `rating`과 `reviewCount`를 사용한다.
62
+ - `no_match`, `ambiguous`, `no_rating`, `error`는 모두 카카오맵 평점을 `미확인`으로 표시한다. 평점을 추측하거나 다른 지점·비슷한 상호의 평점을 대신 쓰지 않는다.
63
+ - 헬퍼 호출이 실패해도 표 생성은 계속한다. 평점 때문에 eat.pl 조회를 다시 시도하지 않는다.
64
+
65
+ 6. 아래 **출력 계약**을 만족하는 표만 사용자에게 보여 준다. 계약을 빠뜨린 표는 미완성이다.
66
+ 7. 반드시 `eat.pl 잇플 · BC카드 결제 데이터 기반`을 출처로 표기한다.
56
67
 
57
68
  ## 출력 계약 (필수)
58
69
 
59
- 표 컬럼: `가맹점명`, `주소`, `업종`, `전국 매출 상위`, `지역 매출 상위`, `동네 맛집`, `외국인 방문`, `회식 적합`, `영업 예상 시간`, `혼잡 요일/시간`, `주요 고객층`, `eat.pl 상세 링크`.
70
+ 표 컬럼: `가맹점명`, `eat.pl 상세 링크`, `주소`, `업종`, `전국 매출 상위`, `지역 매출 상위`, `카카오맵 평점`, `동네 맛집`, `외국인 방문`, `회식 적합`, `영업 예상 시간`, `혼잡 요일/시간`, `주요 고객층`.
60
71
 
61
72
  ### 매출이 얼마나 높은지
62
73
 
@@ -73,10 +84,18 @@ curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v
73
84
 
74
85
  ### 상세 링크 강제
75
86
 
76
- - **모든 행**에 응답 `merUrl`을 마크다운 링크로 첨부한다: `[eat.pl 상세]({merUrl})`.
87
+ - **모든 행**에 응답 `merUrl`을 `가맹점명` 바로 오른쪽 컬럼의 마크다운 링크로 첨부한다: `[eat.pl 상세]({merUrl})`.
88
+ - 링크는 항상 가맹점명 오른쪽에 둔다. 표 맨 뒤 컬럼으로 미루지 않는다.
77
89
  - `merUrl`이 없거나 `https://web.paybooc.ai/`로 시작하지 않으면 그 행을 출력하지 않는다.
78
90
  - 링크가 빠진 목록·표는 완성 결과가 아니다. URL 경로를 조합하거나 추측하지 않는다.
79
91
 
92
+ ### 카카오맵 평점
93
+
94
+ - `카카오맵 평점` 컬럼은 `지역 매출 상위` 바로 오른쪽에 둔다.
95
+ - 헬퍼가 `status: "ok"`를 반환한 행만 `{rating} (리뷰 {reviewCount})`로 쓴다. 예: `2.6 (리뷰 334)`.
96
+ - 그 외 상태(`no_match`, `ambiguous`, `no_rating`, `error`)와 헬퍼 실패는 `미확인`으로 쓴다. 평점이 없다고 0점이나 임의 값을 넣지 않는다.
97
+ - 이 평점은 카카오맵 이용자 평균 평점이며 BC카드 결제 데이터가 아니다. 사용자가 물으면 그렇게 설명한다.
98
+
80
99
  ## Credentials and proxy operations
81
100
 
82
101
  사용자에게 credentials를 묻거나 출력하지 않는다. 운영 환경에 다음 값을 secret으로 주입한다.
@@ -109,6 +128,7 @@ curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v
109
128
  - `502 upstream_semantic_error`: `rspCode`가 성공이 아니거나 응답 구조가 명세와 다름
110
129
  - 개발 서버 연결 타임아웃: 개발 접근 정보 또는 네트워크/IP allowlist를 BC카드 측에 확인
111
130
  - 빈 결과: 주소 텍스트가 너무 좁거나 업종 표기가 맞지 않을 수 있으므로 임의 재시도하지 말고 주소 범위를 넓히거나 명세의 업종값을 확인
131
+ - 카카오맵 평점 조회 실패·모호·무응답: 해당 매장만 `미확인`으로 표시하고 표 생성을 계속한다. 평점 조회 실패로 전체 검색을 실패 처리하지 않는다
112
132
 
113
133
  ## Legal and partnership notice
114
134
 
@@ -118,7 +138,7 @@ curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v
118
138
 
119
139
  - 장소를 `kakao-map`(k-skill-proxy 카카오 공식 Local API)으로 주소로 해석했다.
120
140
  - eat.pl 결과를 최대 100건까지 받아 지도 정보와 대조했다.
121
- - 사용자의 조건으로 필터링한 표에 전국·지역 매출 상위 %, 영업 예상 시간, eat.pl 상세 링크가 **모든 행**에 있다.
141
+ - 사용자의 조건으로 필터링한 표에 전국·지역 매출 상위 %, 영업 예상 시간이 **모든 행**에 있고, eat.pl 상세 링크는 가맹점명 오른쪽, 카카오맵 평점(또는 `미확인`)은 지역 매출 상위 오른쪽에 **모든 행**에 있다.
122
142
  - 매출 순 추천과 최근 1개월 결제 데이터 기준임을 설명했다.
123
143
  - 출처·제휴 전제·데이터 한계를 함께 고지했다.
124
144
  - 사용자 답변에 endpoint, 프록시, 환경변수, 내부 필드명이 없다.
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ // Resolves a merchant's Kakao Map aggregate rating for the bccard-eatpl-search table.
3
+ //
4
+ // Step 1 uses the official Kakao Local keyword search through k-skill-proxy
5
+ // (the REST API key lives server-side) to resolve the merchant to a stable
6
+ // Kakao place id. Step 2 reads the public Kakao Map mobile search page and
7
+ // takes the displayed aggregate rating of that exact place id. Any doubt —
8
+ // no candidate, several candidates, missing rating, unreachable upstream —
9
+ // yields a null rating with an explicit status instead of a guess.
10
+
11
+ import { parseArgs } from "node:util";
12
+ import { pathToFileURL } from "node:url";
13
+
14
+ export const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
15
+ export const KAKAO_MOBILE_SEARCH_URL = "https://m.map.kakao.com/actions/searchView";
16
+ export const DEFAULT_TIMEOUT_MS = 10000;
17
+
18
+ const MOBILE_USER_AGENT =
19
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
20
+ "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
21
+
22
+ const ADDRESS_SIGNIFICANT_SUFFIX = /[구군동읍면리로길]$/;
23
+
24
+ function baseResult(query) {
25
+ return {
26
+ query,
27
+ status: "error",
28
+ kakaoPlaceId: null,
29
+ kakaoPlaceName: null,
30
+ kakaoPlaceUrl: null,
31
+ rating: null,
32
+ reviewCount: null
33
+ };
34
+ }
35
+
36
+ export function tokenSet(text) {
37
+ if (!text) {
38
+ return [];
39
+ }
40
+ return String(text)
41
+ .split(/\s+/)
42
+ .map((token) => token.replace(/[^0-9A-Za-z가-힣]/g, "").toLowerCase())
43
+ .filter((token) => token.length > 0 && /[a-z가-힣]/.test(token));
44
+ }
45
+
46
+ function significantAddressTokens(address) {
47
+ return tokenSet(address).filter((token) => ADDRESS_SIGNIFICANT_SUFFIX.test(token));
48
+ }
49
+
50
+ function sharesSignificantAddressToken(left, right) {
51
+ const rightTokens = new Set(significantAddressTokens(right));
52
+ return significantAddressTokens(left).some((token) => rightTokens.has(token));
53
+ }
54
+
55
+ function candidateAddresses(document) {
56
+ return [document.road_address_name, document.address_name].filter(Boolean);
57
+ }
58
+
59
+ function isAddressCompatible(document, address) {
60
+ return candidateAddresses(document).some((candidateAddress) =>
61
+ sharesSignificantAddressToken(candidateAddress, address)
62
+ );
63
+ }
64
+
65
+ function matchesName(document, needleTokens) {
66
+ const nameTokens = new Set(tokenSet(document.place_name));
67
+ return needleTokens.every((needle) => nameTokens.has(needle));
68
+ }
69
+
70
+ export function pickKakaoCandidate(documents, { name, address }) {
71
+ const needleTokens = tokenSet(name);
72
+ if (needleTokens.length === 0 || !Array.isArray(documents) || documents.length === 0) {
73
+ return { status: "no_match" };
74
+ }
75
+ const nameMatches = documents.filter((document) => matchesName(document, needleTokens));
76
+ if (nameMatches.length === 0) {
77
+ return { status: "no_match" };
78
+ }
79
+ const hasAddressConstraint = significantAddressTokens(address).length > 0;
80
+ if (!hasAddressConstraint) {
81
+ return nameMatches.length === 1
82
+ ? { status: "ok", document: nameMatches[0] }
83
+ : { status: "ambiguous", candidates: nameMatches.length };
84
+ }
85
+ const compatible = nameMatches.filter((document) => isAddressCompatible(document, address));
86
+ if (compatible.length === 1) {
87
+ return { status: "ok", document: compatible[0] };
88
+ }
89
+ return compatible.length === 0 ? { status: "no_match" } : { status: "ambiguous", candidates: compatible.length };
90
+ }
91
+
92
+ function decodeHtmlEntities(value) {
93
+ return value
94
+ .replace(/&quot;/g, '"')
95
+ .replace(/&#39;/g, "'")
96
+ .replace(/&lt;/g, "<")
97
+ .replace(/&gt;/g, ">")
98
+ .replace(/&amp;/g, "&");
99
+ }
100
+
101
+ export function parseSearchViewItems(html) {
102
+ if (typeof html !== "string" || html.length === 0) {
103
+ return [];
104
+ }
105
+ const chunks = html.split('<li class="search_item').slice(1);
106
+ const items = [];
107
+ for (const chunk of chunks) {
108
+ const idMatch = chunk.match(/data-id="(\d+)"/);
109
+ const titleMatch = chunk.match(/data-title="([^"]*)"/);
110
+ if (!idMatch || !titleMatch) {
111
+ continue;
112
+ }
113
+ const ratingIndex = chunk.search(/num_rate">[0-9.]+</);
114
+ let rating = null;
115
+ let reviewCount = null;
116
+ if (ratingIndex !== -1) {
117
+ rating = Number.parseFloat(chunk.match(/num_rate">([0-9.]+)</)[1]);
118
+ const countMatch = chunk.slice(ratingIndex).match(/txt_num">\((\d+)\)/);
119
+ reviewCount = countMatch ? Number.parseInt(countMatch[1], 10) : null;
120
+ }
121
+ items.push({
122
+ id: idMatch[1],
123
+ title: decodeHtmlEntities(titleMatch[1]),
124
+ rating,
125
+ reviewCount
126
+ });
127
+ }
128
+ return items;
129
+ }
130
+
131
+ async function fetchText(url, { fetchImpl, timeoutMs, headers }) {
132
+ const controller = new AbortController();
133
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
134
+ try {
135
+ const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: "follow" });
136
+ if (!response.ok) {
137
+ throw new Error(`HTTP ${response.status}`);
138
+ }
139
+ return await response.text();
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ export async function lookupKakaoPlaceRating({
146
+ name,
147
+ address = "",
148
+ proxyBaseUrl = DEFAULT_PROXY_BASE_URL,
149
+ timeoutMs = DEFAULT_TIMEOUT_MS,
150
+ fetchImpl = fetch
151
+ }) {
152
+ const result = baseResult({ name, address });
153
+ if (!name || !String(name).trim()) {
154
+ result.status = "no_match";
155
+ return result;
156
+ }
157
+
158
+ let documents;
159
+ try {
160
+ const searchUrl =
161
+ `${proxyBaseUrl}/v1/kakao-map/search/keyword?` +
162
+ new URLSearchParams({ query: String(name).trim(), size: "15" });
163
+ const payload = JSON.parse(await fetchText(searchUrl, { fetchImpl, timeoutMs, headers: {} }));
164
+ documents = Array.isArray(payload.documents) ? payload.documents : null;
165
+ if (documents === null) {
166
+ throw new Error("missing documents array");
167
+ }
168
+ } catch (error) {
169
+ result.status = "error";
170
+ result.error = `kakao local search failed: ${error.message}`;
171
+ return result;
172
+ }
173
+
174
+ const picked = pickKakaoCandidate(documents, { name, address });
175
+ if (picked.status !== "ok") {
176
+ result.status = picked.status;
177
+ return result;
178
+ }
179
+
180
+ const place = picked.document;
181
+ result.kakaoPlaceId = place.id;
182
+ result.kakaoPlaceName = place.place_name;
183
+ result.kakaoPlaceUrl = place.place_url || `http://place.map.kakao.com/${place.id}`;
184
+
185
+ let html;
186
+ try {
187
+ const searchViewUrl =
188
+ `${KAKAO_MOBILE_SEARCH_URL}?` + new URLSearchParams({ q: String(name).trim() });
189
+ html = await fetchText(searchViewUrl, {
190
+ fetchImpl,
191
+ timeoutMs,
192
+ headers: { "user-agent": MOBILE_USER_AGENT, "accept-language": "ko-KR,ko;q=0.9" }
193
+ });
194
+ } catch (error) {
195
+ result.status = "error";
196
+ result.error = `kakao map rating page failed: ${error.message}`;
197
+ return result;
198
+ }
199
+
200
+ const item = parseSearchViewItems(html).find((candidate) => candidate.id === place.id);
201
+ if (!item || item.rating === null) {
202
+ result.status = "no_rating";
203
+ return result;
204
+ }
205
+ result.status = "ok";
206
+ result.rating = item.rating;
207
+ result.reviewCount = item.reviewCount;
208
+ return result;
209
+ }
210
+
211
+ function printUsage() {
212
+ console.error(
213
+ "usage: kakao-place-rating.mjs --name <가맹점명> [--address <주소>] " +
214
+ "[--proxy-base-url <url>] [--timeout-ms <ms>]"
215
+ );
216
+ }
217
+
218
+ export async function main(argv = process.argv.slice(2)) {
219
+ let args;
220
+ try {
221
+ args = parseArgs({
222
+ args: argv,
223
+ options: {
224
+ name: { type: "string" },
225
+ address: { type: "string", default: "" },
226
+ "proxy-base-url": { type: "string", default: process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL },
227
+ "timeout-ms": { type: "string", default: String(DEFAULT_TIMEOUT_MS) },
228
+ help: { type: "boolean", default: false }
229
+ }
230
+ });
231
+ } catch {
232
+ printUsage();
233
+ return 2;
234
+ }
235
+ if (args.values.help || !args.values.name) {
236
+ printUsage();
237
+ return args.values.help ? 0 : 2;
238
+ }
239
+ const timeoutMs = Number.parseInt(args.values["timeout-ms"], 10);
240
+ const result = await lookupKakaoPlaceRating({
241
+ name: args.values.name,
242
+ address: args.values.address,
243
+ proxyBaseUrl: args.values["proxy-base-url"],
244
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS
245
+ });
246
+ process.stdout.write(`${JSON.stringify(result)}\n`);
247
+ return 0;
248
+ }
249
+
250
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
251
+ main().then((code) => {
252
+ process.exitCode = code;
253
+ });
254
+ }
@@ -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)
@@ -0,0 +1,344 @@
1
+ """공유누리(eshare.go.kr) 공공개방자원 검색 helper.
2
+
3
+ 행정안전부 공유누리 포털의 로그인 없이 열려 있는 통합검색 표면을 호출해
4
+ 전국 공공개방자원(회의실·체육시설·숙소·교육·물품 등)을 조회한다.
5
+ API 인증키가 필요 없는 공개 read-only 경로만 사용하며, 예약 신청·로그인·
6
+ 결제 같은 쓰기 흐름은 자동화하지 않는다.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import http.cookiejar
13
+ import json
14
+ import re
15
+ import sys
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+
20
+ BASE_URL = "https://www.eshare.go.kr"
21
+ BOOTSTRAP_URL = BASE_URL + "/UserPortal/adv/unifdSearch/UnifiedSearchMainView.do"
22
+ SEARCH_URL = BASE_URL + "/UserPortal/adv/unifdSearch/ConditionsSearch.do"
23
+ SIGUNGU_URL = BASE_URL + "/UserPortal/comm/zip/selectsigunguList.do"
24
+ CATEGORY_URL = BASE_URL + "/UserPortal/Upm/searchUserPortalRsrcCls.do"
25
+ USER_AGENT = "k-skill-sharenuri-facility-search/0.1 (+https://github.com/NomaDamas/k-skill)"
26
+ REQUEST_TIMEOUT = 20
27
+ PAGE_SIZE = 10
28
+ MAX_PAGES = 5
29
+
30
+ # 통합검색 상세조건의 시·도 선택 코드 (2026-09-12 공유누리 통합검색 화면 실측)
31
+ SIDO_CODES = {
32
+ "서울특별시": "11", "부산광역시": "26", "인천광역시": "28", "대전광역시": "30",
33
+ "전남광주통합특별시": "12", "대구광역시": "27", "울산광역시": "31",
34
+ "세종특별자치시": "36", "경기도": "41", "충청북도": "43", "충청남도": "44",
35
+ "전북특별자치도": "52", "경상북도": "47", "경상남도": "48",
36
+ "강원특별자치도": "51", "제주특별자치도": "50",
37
+ }
38
+
39
+ # 검색결과의 rsrcDcd 7~9번째 자리 → 상세페이지 경로 (통합검색 JS fnNewPageResourceDetail 기준)
40
+ DETAIL_PATH_BY_DCD = {
41
+ "001": "Upv/UprResrcGym", "002": "Upv/UprResrcFacl", "098": "Upv/UprResrcFacl",
42
+ "003": "Upv/UprResrcCamp", "004": "Upv/UprResrcEqp", "005": "Upv/UprResrcGds",
43
+ "006": "Upv/UprResrcEdu", "007": "Upv/UprResrcExpr", "008": "Upv/UprResrcPfm",
44
+ "009": "Upv/UprResrcEvnt",
45
+ }
46
+
47
+ _TAG_RE = re.compile(r"<[^>]+>")
48
+ _WS_RE = re.compile(r"\s+")
49
+ _TOTAL_RE = re.compile(r"총\s*<span>([\d,]+)</span>\s*건")
50
+ _ITEM_SPLIT_RE = re.compile(r'<li>\s*<!--\s*이미지영역', re.S)
51
+ _RSRC_RE = re.compile(r"fnNewPageResourceDetail\('([^']+)',\s*'([^']+)'\)")
52
+ _SR_ONLY_RE = re.compile(r'<span class="sr-only">.*?</span>', re.S)
53
+ _DETAIL_ROW_RE = re.compile(
54
+ r'<th[^>]*scope="row"[^>]*>(.*?)</th>\s*<td[^>]*>(.*?)</td>', re.S
55
+ )
56
+
57
+
58
+ class SharenuriError(RuntimeError):
59
+ pass
60
+
61
+
62
+ def _strip_html(fragment: str) -> str:
63
+ text = _TAG_RE.sub(" ", fragment)
64
+ text = text.replace("&nbsp;", " ").replace("&middot;", "·").replace("&amp;", "&")
65
+ return _WS_RE.sub(" ", text).strip()
66
+
67
+
68
+ def _open_session():
69
+ jar = http.cookiejar.CookieJar()
70
+ opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
71
+ opener.addheaders = [("User-Agent", USER_AGENT)]
72
+ return opener
73
+
74
+
75
+ def _request(opener, url: str, data: dict | None = None, referer: str | None = None) -> str:
76
+ body = None
77
+ headers = {}
78
+ if data is not None:
79
+ body = urllib.parse.urlencode(data).encode("utf-8")
80
+ headers["X-Requested-With"] = "XMLHttpRequest"
81
+ if referer:
82
+ headers["Referer"] = referer
83
+ req = urllib.request.Request(url, data=body, headers=headers)
84
+ try:
85
+ with opener.open(req, timeout=REQUEST_TIMEOUT) as resp:
86
+ return resp.read().decode("utf-8", errors="replace")
87
+ except urllib.error.URLError as exc:
88
+ raise SharenuriError(f"공유누리 서버에 연결하지 못했습니다: {exc}") from exc
89
+
90
+
91
+ def _bootstrap(opener) -> None:
92
+ # 통합검색 화면을 한 번 열어 세션 쿠키를 받는다 (검색 POST의 선행 조건)
93
+ _request(opener, BOOTSTRAP_URL)
94
+
95
+
96
+ def normalize_sigg(sigg: str | None) -> str | None:
97
+ # 공유누리 통합검색 select[name=sigg] 값은 gunguCd (강남구 680).
98
+ # 법정동 시군구코드 5자리(11680)가 들어오면 뒤 3자리를 쓴다.
99
+ if not sigg:
100
+ return None
101
+ value = str(sigg).strip()
102
+ if len(value) == 5 and value.isdigit():
103
+ return value[-3:]
104
+ return value
105
+
106
+
107
+ def detail_path_for(rsrc_dcd: str) -> str:
108
+ dcd = rsrc_dcd[7:10]
109
+ path = DETAIL_PATH_BY_DCD.get(dcd)
110
+ if path is None:
111
+ raise SharenuriError(f"알 수 없는 자원 분류 코드입니다: {rsrc_dcd}")
112
+ return path
113
+
114
+
115
+ def detail_url(rsrc_dcd: str, rsrc_no: str) -> str:
116
+ return (
117
+ f"{BASE_URL}/UserPortal/{detail_path_for(rsrc_dcd)}/index.do"
118
+ f"?rsrc_no={urllib.parse.quote(rsrc_no)}&rsrc_dcd={urllib.parse.quote(rsrc_dcd)}"
119
+ )
120
+
121
+
122
+ def parse_search_fragment(html: str) -> dict:
123
+ # 검색어 하이라이트 마커가 title 속성 안에까지 들어 있어 태그 파싱을 깨뜨리므로 먼저 제거
124
+ html = html.replace("<!HS>", "").replace("<!HE>", "")
125
+ total_match = _TOTAL_RE.search(html)
126
+ total = int(total_match.group(1).replace(",", "")) if total_match else 0
127
+ items = []
128
+ for block in _ITEM_SPLIT_RE.split(html)[1:]:
129
+ block = "<li>" + block
130
+ block = _SR_ONLY_RE.sub("", block) # 접근성 숨김 텍스트(시설명 반복) 제거
131
+ id_match = _RSRC_RE.search(block)
132
+ if not id_match:
133
+ continue
134
+ rsrc_dcd, rsrc_no = id_match.group(1), id_match.group(2)
135
+ text = _strip_html(block)
136
+ item = {
137
+ "rsrc_dcd": rsrc_dcd,
138
+ "rsrc_no": rsrc_no,
139
+ "detail_url": detail_url(rsrc_dcd, rsrc_no),
140
+ }
141
+ title_match = re.search(r'<a class="sbj_txt"[^>]*>(.*?)</a>', block, re.S)
142
+ if title_match:
143
+ item["title"] = _strip_html(title_match.group(1))
144
+ for label, key in (("자원 분류", "category"), ("위치 정보", "location"), ("이용 요금", "fee")):
145
+ seg = re.search(label + r"\s+(.*?)(?:자원 분류|위치 정보|이용 요금|지도 보기|예약·문의하기|$)", text)
146
+ if seg and seg.group(1):
147
+ item[key] = seg.group(1).strip()
148
+ items.append(item)
149
+ return {"total_count": total, "items": items}
150
+
151
+
152
+ def parse_detail_page(html: str) -> dict:
153
+ html = html.replace("<!HS>", "").replace("<!HE>", "")
154
+ fields = {}
155
+ for label, value in _DETAIL_ROW_RE.findall(html):
156
+ label_text = _strip_html(label)
157
+ value_text = _strip_html(value)
158
+ value_text = re.sub(r"\s*지도보기$", "", value_text)
159
+ value_text = value_text.replace("-->", "").strip()
160
+ if label_text:
161
+ fields[label_text] = value_text
162
+ return fields
163
+
164
+
165
+ def cmd_search(opener, args) -> dict:
166
+ sido_code = args.ctrd
167
+ if args.sido:
168
+ if args.sido not in SIDO_CODES:
169
+ raise SharenuriError(
170
+ "알 수 없는 시·도 이름입니다. 사용 가능: " + ", ".join(SIDO_CODES)
171
+ )
172
+ sido_code = SIDO_CODES[args.sido]
173
+ limit = max(1, min(args.limit, PAGE_SIZE * MAX_PAGES))
174
+ pages = min(MAX_PAGES, (limit + PAGE_SIZE - 1) // PAGE_SIZE)
175
+ collected: list[dict] = []
176
+ total = 0
177
+ _bootstrap(opener)
178
+ for offset in range(pages):
179
+ payload = {
180
+ "searchWrd": args.query,
181
+ "sort": "RANK",
182
+ "order": "DESC",
183
+ "pageIndex": str(args.page + offset),
184
+ "collection": "resource_cate1",
185
+ "viewType": "UnitPage",
186
+ }
187
+ if sido_code:
188
+ payload["ctrd"] = sido_code
189
+ sigg = normalize_sigg(args.sigg)
190
+ if sigg:
191
+ payload["sigg"] = sigg
192
+ if args.major_code:
193
+ payload["mj_rsrc_cls_cd"] = args.major_code
194
+ if args.free:
195
+ payload["free_yn"] = "Y"
196
+ if args.reservable:
197
+ payload["intnet_rsrv_psbl_yn"] = "Y"
198
+ html = _request(opener, SEARCH_URL, data=payload, referer=BOOTSTRAP_URL)
199
+ parsed = parse_search_fragment(html)
200
+ total = parsed["total_count"]
201
+ collected.extend(parsed["items"])
202
+ if len(parsed["items"]) < PAGE_SIZE:
203
+ break
204
+ return {
205
+ "query": {
206
+ "searchWrd": args.query,
207
+ "ctrd": sido_code,
208
+ "sigg": normalize_sigg(args.sigg),
209
+ "mj_rsrc_cls_cd": args.major_code,
210
+ "free_yn": "Y" if args.free else None,
211
+ "intnet_rsrv_psbl_yn": "Y" if args.reservable else None,
212
+ "page": args.page,
213
+ },
214
+ "total_count": total,
215
+ "items": collected[:limit],
216
+ }
217
+
218
+
219
+ def cmd_detail(opener, args) -> dict:
220
+ url = detail_url(args.rsrc_dcd, args.rsrc_no) if args.rsrc_dcd else None
221
+ if url is None:
222
+ raise SharenuriError("--rsrc-dcd 가 필요합니다 (검색 결과의 rsrc_dcd 값)")
223
+ html = _request(opener, url)
224
+ fields = parse_detail_page(html)
225
+ if not fields:
226
+ raise SharenuriError(
227
+ "상세 페이지에서 기본정보를 찾지 못했습니다. rsrc_no/rsrc_dcd 확인 또는 공유누리 화면 구조 변경 가능성이 있습니다."
228
+ )
229
+ return {"detail_url": url, "fields": fields}
230
+
231
+
232
+ def cmd_sigungu(opener, args) -> dict:
233
+ body = _request(opener, SIGUNGU_URL, data={"addr": args.ctrd})
234
+ try:
235
+ rows = json.loads(body).get("signguNmList", [])
236
+ except json.JSONDecodeError as exc:
237
+ raise SharenuriError("시군구 목록 응답이 JSON이 아닙니다.") from exc
238
+ return {
239
+ "ctrd": args.ctrd,
240
+ "sigungu": [
241
+ {
242
+ "sigunguCd": row.get("sigunguCd"),
243
+ "gunguCd": row.get("gunguCd"),
244
+ "name": row.get("gunguNm"),
245
+ }
246
+ for row in rows
247
+ if row.get("openAt") == "Y"
248
+ ],
249
+ }
250
+
251
+
252
+ def cmd_categories(opener, args) -> dict:
253
+ body = _request(opener, CATEGORY_URL, data={"rsrcClsCd": "1"})
254
+ try:
255
+ rows = json.loads(body).get("rsrcClsList", [])
256
+ except json.JSONDecodeError as exc:
257
+ raise SharenuriError("자원 대분류 응답이 JSON이 아닙니다.") from exc
258
+ return {
259
+ "major_categories": [
260
+ {"code": row.get("rsrc_cls_cd"), "name": row.get("rsrc_cls_nm")} for row in rows
261
+ ]
262
+ }
263
+
264
+
265
+ def _print(result: dict, as_json: bool) -> None:
266
+ if as_json:
267
+ print(json.dumps(result, ensure_ascii=False, indent=2))
268
+ return
269
+ if "items" in result:
270
+ q = result["query"]
271
+ print(f"검색어: {q['searchWrd']} / 총 {result['total_count']:,}건 (표시 {len(result['items'])}건)")
272
+ for idx, item in enumerate(result["items"], 1):
273
+ print(f"\n[{idx}] {item.get('title', '(제목 없음)')}")
274
+ if item.get("category"):
275
+ print(f" 분류: {item['category']}")
276
+ if item.get("location"):
277
+ print(f" 위치: {item['location']}")
278
+ if item.get("fee"):
279
+ print(f" 요금: {item['fee']}")
280
+ print(f" 상세: {item['detail_url']}")
281
+ elif "fields" in result:
282
+ print(f"상세 페이지: {result['detail_url']}")
283
+ for label, value in result["fields"].items():
284
+ print(f" {label}: {value}")
285
+ elif "sigungu" in result:
286
+ for row in result["sigungu"]:
287
+ print(f"{row['gunguCd']}\t{row['name']}\t{row['sigunguCd']}")
288
+ elif "major_categories" in result:
289
+ for row in result["major_categories"]:
290
+ print(f"{row['code']}\t{row['name']}")
291
+
292
+
293
+ def build_parser() -> argparse.ArgumentParser:
294
+ parser = argparse.ArgumentParser(
295
+ description="공유누리 공공개방자원 검색 (로그인·API 키 불필요, 조회 전용)"
296
+ )
297
+ parser.add_argument("--json", action="store_true", help="JSON 출력")
298
+ sub = parser.add_subparsers(dest="command", required=True)
299
+
300
+ p_search = sub.add_parser("search", help="자원 통합검색")
301
+ p_search.add_argument("--query", "-q", required=True, help="검색어 (예: 회의실)")
302
+ p_search.add_argument("--sido", help="시·도 이름 (예: 서울특별시)")
303
+ p_search.add_argument("--ctrd", help="시·도 코드 (예: 11)")
304
+ p_search.add_argument("--sigg", help="시군구 코드 (sigungu 하위명령으로 조회)")
305
+ p_search.add_argument("--major-code", help="자원 대분류 코드 (categories 하위명령으로 조회)")
306
+ p_search.add_argument("--free", action="store_true", help="무료 자원만")
307
+ p_search.add_argument("--reservable", action="store_true", help="인터넷 예약 가능 자원만")
308
+ p_search.add_argument("--page", type=int, default=1, help="시작 페이지 (기본 1)")
309
+ p_search.add_argument("--limit", type=int, default=10, help="최대 건수 (기본 10, 최대 50)")
310
+
311
+ p_detail = sub.add_parser("detail", help="자원 상세정보 조회")
312
+ p_detail.add_argument("--rsrc-no", required=True, help="자원번호 (검색 결과의 rsrc_no)")
313
+ p_detail.add_argument("--rsrc-dcd", required=True, help="자원분류코드 (검색 결과의 rsrc_dcd)")
314
+
315
+ p_sigungu = sub.add_parser("sigungu", help="시·도 코드의 시군구 목록")
316
+ p_sigungu.add_argument("--ctrd", required=True, help="시·도 코드 (예: 11)")
317
+
318
+ sub.add_parser("categories", help="자원 대분류 코드 목록")
319
+ return parser
320
+
321
+
322
+ def main(argv: list[str] | None = None) -> int:
323
+ args = build_parser().parse_args(argv)
324
+ if getattr(args, "page", 1) < 1:
325
+ print("오류: --page 는 1 이상이어야 합니다.", file=sys.stderr)
326
+ return 2
327
+ opener = _open_session()
328
+ handlers = {
329
+ "search": cmd_search,
330
+ "detail": cmd_detail,
331
+ "sigungu": cmd_sigungu,
332
+ "categories": cmd_categories,
333
+ }
334
+ try:
335
+ result = handlers[args.command](opener, args)
336
+ except SharenuriError as exc:
337
+ print(f"오류: {exc}", file=sys.stderr)
338
+ return 1
339
+ _print(result, args.json)
340
+ return 0
341
+
342
+
343
+ if __name__ == "__main__":
344
+ sys.exit(main())
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "sharenuri-facility-search",
3
+ "description": "공유누리(eshare.go.kr) 공개 통합검색으로 전국 공공개방자원(회의실·체육시설·숙소·교육·물품 등)을 인증키·로그인 없이 검색하고, 상세정보(요금·정원·예약방법·문의처)와 공식 예약 경로를 안내한다. 조회 전용.",
4
+ "profiles": [
5
+ "lookup"
6
+ ],
7
+ "frontmatter": "name: sharenuri-facility-search\ndescription: 공유누리(eshare.go.kr) 공개 통합검색으로 전국 공공개방자원(회의실·체육시설·숙소·교육·물품 등)을 인증키·로그인 없이 검색하고, 상세정보(요금·정원·예약방법·문의처)와 공식 예약 경로를 안내한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: civic\n locale: ko-KR\n phase: v1"
8
+ }