@nomadamas/k-skill 0.2.5 → 0.3.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,27 @@
1
+ version = 1
2
+ revision = 1
3
+ requires-python = ">=3.11"
4
+
5
+ [manifest]
6
+ requirements = [{ name = "openpyxl", specifier = "==3.1.5" }]
7
+
8
+ [[package]]
9
+ name = "et-xmlfile"
10
+ version = "2.0.0"
11
+ source = { registry = "https://pypi.org/simple" }
12
+ sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234 }
13
+ wheels = [
14
+ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059 },
15
+ ]
16
+
17
+ [[package]]
18
+ name = "openpyxl"
19
+ version = "3.1.5"
20
+ source = { registry = "https://pypi.org/simple" }
21
+ dependencies = [
22
+ { name = "et-xmlfile" },
23
+ ]
24
+ sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464 }
25
+ wheels = [
26
+ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910 },
27
+ ]
@@ -0,0 +1,141 @@
1
+ """Parse direction-aware KTX timetable sections from official workbooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import dataclass
8
+ from datetime import date as calendar_date
9
+ from datetime import datetime, time
10
+
11
+ TIME_VALUE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
12
+ TRAIN_NUMBER = re.compile(r"^\d{1,4}$")
13
+ WEEKDAY_NAMES = "월화수목금토일"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class TimetableSection:
18
+ train_index: int
19
+ type_index: int
20
+ dep_index: int
21
+ arr_index: int
22
+ note_index: int
23
+
24
+
25
+ def normalize_station(value: object) -> str:
26
+ return re.sub(r"\s+", "", str(value or "")).replace("역", "")
27
+
28
+
29
+ def normalize_time(value: object) -> str | None:
30
+ if value is None:
31
+ return None
32
+ if isinstance(value, datetime):
33
+ return value.strftime("%H:%M")
34
+ if isinstance(value, time):
35
+ return None if value == time.min else value.strftime("%H:%M")
36
+ text = str(value).strip()
37
+ if TIME_VALUE.fullmatch(text):
38
+ return text
39
+ if re.fullmatch(r"\d{3,4}", text):
40
+ return f"{int(text) // 100:02d}:{int(text) % 100:02d}"
41
+ return None
42
+
43
+
44
+ def header_sections(values: list[str], dep: str, arr: str) -> list[TimetableSection]:
45
+ starts = [index for index, value in enumerate(values) if value == "열차번호"]
46
+ sections: list[TimetableSection] = []
47
+ for position, start in enumerate(starts):
48
+ end = starts[position + 1] if position + 1 < len(starts) else len(values)
49
+ dep_indices = [index for index in range(start, end) if values[index] == dep]
50
+ arr_indices = [index for index in range(start, end) if values[index] == arr]
51
+ if not dep_indices or not arr_indices:
52
+ continue
53
+ dep_index = dep_indices[0]
54
+ arr_index = arr_indices[0]
55
+ if dep_index >= arr_index:
56
+ continue
57
+ type_indices = [index for index in range(start, end) if values[index] == "편성"]
58
+ note_indices = [index for index in range(start, end) if values[index] == "비고"]
59
+ sections.append(
60
+ TimetableSection(
61
+ train_index=start,
62
+ type_index=type_indices[0] if type_indices else -1,
63
+ dep_index=dep_index,
64
+ arr_index=arr_index,
65
+ note_index=note_indices[0] if note_indices else -1,
66
+ )
67
+ )
68
+ return sections
69
+
70
+
71
+ def runs_on_date(value: object, requested_date: calendar_date) -> bool:
72
+ text = re.sub(r"\s+", "", str(value or ""))
73
+ if not text or "매일" in text:
74
+ return True
75
+ if text == "평일":
76
+ return requested_date.weekday() < 5
77
+ compact = re.sub(r"[,./·~\-]", "", text)
78
+ if re.fullmatch(r"[월화수목금토일]+", compact):
79
+ return WEEKDAY_NAMES[requested_date.weekday()] in compact
80
+ return True
81
+
82
+
83
+ def parse_timetable_rows(
84
+ rows: Iterable[Iterable[object]],
85
+ *,
86
+ dep: str,
87
+ arr: str,
88
+ requested_date: calendar_date,
89
+ earliest: str,
90
+ latest: str,
91
+ ) -> tuple[list[dict[str, str]], bool]:
92
+ dep_name = normalize_station(dep)
93
+ arr_name = normalize_station(arr)
94
+ sections: list[TimetableSection] = []
95
+ route_found = False
96
+ results: list[dict[str, str]] = []
97
+
98
+ for raw_row in rows:
99
+ row = list(raw_row)
100
+ normalized = [normalize_station(value) for value in row]
101
+ if "열차번호" in normalized:
102
+ sections = header_sections(normalized, dep_name, arr_name)
103
+ route_found = route_found or bool(sections)
104
+ continue
105
+ for section in sections:
106
+ indices = (
107
+ section.train_index,
108
+ section.type_index,
109
+ section.dep_index,
110
+ section.arr_index,
111
+ section.note_index,
112
+ )
113
+ if max(indices) >= len(row):
114
+ continue
115
+ train_no = str(row[section.train_index] or "").strip()
116
+ if not TRAIN_NUMBER.fullmatch(train_no):
117
+ continue
118
+ train_type = (
119
+ str(row[section.type_index] or "").strip().upper()
120
+ if section.type_index >= 0
121
+ else "KTX"
122
+ )
123
+ if "KTX" not in train_type:
124
+ continue
125
+ if section.note_index >= 0 and not runs_on_date(row[section.note_index], requested_date):
126
+ continue
127
+ dep_time = normalize_time(row[section.dep_index])
128
+ arr_time = normalize_time(row[section.arr_index])
129
+ if dep_time is None or arr_time is None or not earliest <= dep_time <= latest:
130
+ continue
131
+ results.append(
132
+ {
133
+ "train_no": train_no,
134
+ "train_type": train_type,
135
+ "dep": dep,
136
+ "arr": arr,
137
+ "dep_time": dep_time,
138
+ "arr_time": arr_time,
139
+ }
140
+ )
141
+ return results, route_found
@@ -1,16 +1,22 @@
1
1
  {
2
2
  "name": "ktx-booking",
3
- "description": "Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, reservation status, remaining seat numbers, car-by-car seats, or power-outlet/good-seat tips. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
3
+ "description": "Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.",
4
4
  "profiles": [
5
- "vault",
6
- "browser",
7
- "action:booking"
5
+ "lookup"
8
6
  ],
9
- "frontmatter": "name: ktx-booking\ndescription: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, reservation status, remaining seat numbers, car-by-car seats, or power-outlet/good-seat tips. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1",
7
+ "frontmatter": "name: ktx-booking\ndescription: Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1",
10
8
  "bundle": [
11
9
  {
12
10
  "from": "scripts/ktx_booking.py",
13
11
  "to": "scripts/ktx_booking.py"
12
+ },
13
+ {
14
+ "from": "scripts/ktx_timetable.py",
15
+ "to": "scripts/ktx_timetable.py"
16
+ },
17
+ {
18
+ "from": "scripts/ktx_booking.py.lock",
19
+ "to": "scripts/ktx_booking.py.lock"
14
20
  }
15
21
  ]
16
22
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## What this skill does
4
4
 
5
- 서울 행정동 이름을 정규 `place_id`로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대 단일 제품(`weather_place_risk_window`)을 읽기 전용으로 탐색한다. 기본 helper는 hosted `k-skill-proxy`를 호출하며, 사용자 API Key 발급받거나 저장하지 않는다. 등록 로컬 검증에는 명시적으로 opt-in한 local-direct 경로를 사용할 수 있다. 실패 또는 미준비 상태를 fixture나 추정값으로 대체하지 않는다.
5
+ 서울 행정동 이름을 정규 `place_id`로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대 단일 제품(`weather_place_risk_window`)을 읽기 전용으로 탐색한다. 기본 helper는 hosted `k-skill-proxy`만 호출하며, 사용자 API Key 현재 작업 폴더의 `.env`를 읽지 않는다. 실패 또는 미준비 상태를 fixture나 추정값으로 대체하지 않는다.
6
6
 
7
7
  ## Product
8
8
 
@@ -26,58 +26,52 @@
26
26
 
27
27
  ## Workflow
28
28
 
29
- 1. 환경 설정만 먼저 확인한다. 이 명령은 네트워크를 호출하지 않는다.
29
+ ### Standard user query (fast path)
30
30
 
31
- ```bash
32
- npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- preflight
33
- ```
34
-
35
- 2. bundle에서 이 제품의 준비 상태를 확인한다.
36
-
37
- ```bash
38
- npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- catalog
39
- ```
40
-
41
- 3. 제품의 grain, 기본키, 시간축, 공개 column 및 증거 metadata를 확인한다.
42
-
43
- ```bash
44
- npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- describe --product-id weather_place_risk_window
45
- ```
46
-
47
- 4. 행정동 이름과 `1..500` 범위의 limit으로 data page를 조회한다.
31
+ 사용자가 오늘 위험 시간대를 묻는 기본 경로는 `query --fast` 한 번만 실행한다. 이 경로는 bundled 행정동 매핑과 날짜·limit 검증을 유지하면서 hosted data route만 한 번 호출하므로 bundle·product metadata 왕복을 생략한다. `--fast`에서는 `--filter`를 사용하지 않고 `--admin-dong`, `--gu`, 날짜, `--limit`, `--cursor`만 사용한다.
48
32
 
49
33
  ```bash
50
- npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query \
34
+ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query --fast \
51
35
  --product-id weather_place_risk_window \
52
36
  --admin-dong 잠실본동 \
53
- --from 2026-08-01 \
54
- --to 2026-08-07 \
37
+ --from 2026-08-12 \
38
+ --to 2026-08-12 \
55
39
  --limit 100
56
40
  ```
57
41
 
58
- 동명이명인 `신사동`은 자치구를 확인한 뒤 다음처럼 조회한다.
42
+ 동명이명인 `신사동`은 자치구를 확인한 뒤 fast path에도 `--gu`를 함께 전달한다.
59
43
 
60
44
  ```bash
61
- npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query \
45
+ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- query --fast \
62
46
  --product-id weather_place_risk_window \
63
47
  --admin-dong 신사동 \
64
48
  --gu 강남구 \
65
49
  --limit 100
66
50
  ```
67
51
 
68
- 응답의 `registration_ready`, `publication_id`, `blockers`를 먼저 확인한다. data 응답의 `next_cursor`는 같은 제품의 다음 page에만 그대로 재사용한다. publication이 바뀌면 cursor는 `409`로 만료된다.
52
+ `--filter`가 필요하거나 게시 계약을 점검해야 할 때는 `--fast`를 빼고 full-contract query를 사용한다. fast query가 `product_not_ready` 또는 계약 오류를 반환하면 fixture나 추정값으로 대체하지 말고 아래 진단 흐름을 수행한다.
53
+
54
+ ### Contract diagnostics (only when needed)
69
55
 
70
- ## Local pre-registration fallback
56
+ 1. 환경 설정만 확인한다. 이 명령은 네트워크를 호출하지 않는다.
57
+
58
+ ```bash
59
+ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- preflight
60
+ ```
71
61
 
72
- hosted proxy가 아직 등록·배포되지 않은 상태에서 로컬 검증이 필요할 때만, **현재 작업 디렉터리**의 `.env`에 다음 세 이름을 설정한다.
62
+ 2. bundle에서 제품의 준비 상태를 확인한다.
73
63
 
74
- ```text
75
- KSKILL_LOCAL_DIRECT=1
76
- ASK_SEOUL_SKILL_API_BASE_URL=https://ask-seoul.kr
77
- MARKETPLACE_API_KEY=<기존 로컬 Marketplace 키>
64
+ ```bash
65
+ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- catalog
66
+ ```
67
+
68
+ 3. 제품의 grain, 기본키, 시간축, 공개 column 및 증거 metadata를 확인한다.
69
+
70
+ ```bash
71
+ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.py -- describe --product-id weather_place_risk_window
78
72
  ```
79
73
 
80
- opt-in 모드에서는 helper가 `.env`에서 세 이름만 메모리로 읽고 `Authorization: Bearer`로 전달한다. bundle, 단일 `weather_place_risk_window` product, data 직접 경로 이외에는 호출하지 않는다. 값은 명령행 인수·출력·로그·skill 파일에 넣지 않는다. `KSKILL_LOCAL_DIRECT`가 없으면 기존 hosted-proxy 경로를 그대로 사용한다.
74
+ 진단 응답의 `registration_ready`, `publication_id`, `blockers`를 확인한다. fast/full data 응답의 `next_cursor`는 같은 제품의 다음 page에만 그대로 재사용하며 publication이 바뀌면 cursor는 `409`로 만료된다.
81
75
 
82
76
  ## Boundaries
83
77
 
@@ -85,9 +79,9 @@ MARKETPLACE_API_KEY=<기존 로컬 Marketplace 키>
85
79
  - 알 수 없는 제품이나 필터를 추측해 보정하지 않는다.
86
80
  - 행정동 이름을 fuzzy match하거나 모호한 후보 중 하나로 임의 선택하지 않는다. 생활권·통칭 또는 부분 이름(예: `성수동`)도 행정동으로 추측하지 않는다. helper는 로컬 reference에서 `place_id`를 해석하고 proxy에는 행정동·자치구 문자열을 보내지 않는다.
87
81
  - 기본 proxy origin은 `https://k-skill-proxy.nomadamas.org`이다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL`을 HTTPS origin으로 설정한다. 값은 명령행 인수, 문서, 로그에 넣지 않는다.
88
- - hosted-proxy 모드에서는 사용자 API Key와 `Authorization` 헤더를 사용하지 않는다. ASK Seoul 전용 서비스 키는 proxy 운영 환경에만 두며, Marketplace의 `k-skill-proxy:seoul-weather-risk` principal에 `skill:seoul-weather-risk:read` scope로 등록한다. 이 scope는 bundle·product·data 읽기만 허용하고 다른 Marketplace API를 거부한다. local-direct 모드에서만 현재 작업 폴더 `.env`의 `MARKETPLACE_API_KEY`를 세 direct read 경로에 전달하며, 어떤 모드에서도 키를 출력·로그·skill 파일에 넣지 않는다.
82
+ - hosted-proxy 모드에서는 사용자 API Key와 `Authorization` 헤더를 사용하지 않는다. ASK Seoul 전용 서비스 키는 proxy 운영 환경에만 두며, Marketplace의 `k-skill-proxy:seoul-weather-risk` principal에 `skill:seoul-weather-risk:read` scope로 등록한다. 이 scope는 bundle·product·data 읽기만 허용하고 다른 Marketplace API를 거부한다. 어떤 모드에서도 키를 출력·로그·skill 파일에 넣지 않는다.
89
83
  - proxy는 bundle, 단일 product, 그 data 조회만 노출한다. `table name`, SQL, join, sort, aggregate 및 비허용 query field는 upstream으로 전달하지 않는다.
90
- - `/skill/v1/bundles/seoul-weather-risk`의 제품 집합이 `weather_place_risk_window` 단일 제품과 다르면 응답 계약 오류로 중단한다.
84
+ - `/v1/ask-seoul/weather-risk/bundle`의 제품 집합이 `weather_place_risk_window` 단일 제품과 다르면 응답 계약 오류로 중단한다.
91
85
  - live 실패를 fixture나 synthetic 결과로 대체하지 않는다.
92
86
  - 이 제품은 예보값 임계치 기반 참고 정보이며 기상청 공식 특보를 대체하지 않는다는 점을 응답에서 명확히 한다.
93
87
 
@@ -104,7 +98,6 @@ MARKETPLACE_API_KEY=<기존 로컬 Marketplace 키>
104
98
  - `ambiguous_admin_dong`: 동명이거나 별칭 후보가 충돌해 `--gu`가 필요함. `details.candidates`에서 가능한 자치구를 확인한다.
105
99
  - `location_mapping_invalid`: bundled 행정동 reference의 버전·스키마·행 수 계약 오류
106
100
  - `proxy_disabled`, `invalid_proxy_base_url`: proxy 환경 설정 오류
107
- - `local_direct_not_configured`, `invalid_local_direct_base_url`: local-direct 환경 설정 오류
108
101
  - `unauthorized`/`api_key_missing`(401), `forbidden`/`api_key_forbidden`(403), `unknown_product`(404)
109
102
  - `cursor_expired`(409), `rate_limited`(429), `product_not_ready`(503)
110
103
  - `upstream_not_configured`(503): proxy 운영 환경에 ASK Seoul 전용 서비스 키 또는 origin이 설정되지 않음
@@ -20,16 +20,7 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener
20
20
  SKILL_BUNDLE_ID = "seoul-weather-risk"
21
21
  PROXY_BASE_URL_ENV = "KSKILL_PROXY_BASE_URL"
22
22
  DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
23
- LOCAL_DIRECT_ENV = "KSKILL_LOCAL_DIRECT"
24
- SKILL_API_BASE_URL_ENV = "ASK_SEOUL_SKILL_API_BASE_URL"
25
- MARKETPLACE_API_KEY_ENV = "MARKETPLACE_API_KEY"
26
23
  PROXY_DISABLED_VALUES = frozenset({"off", "false", "0", "disable", "disabled", "none"})
27
- LOCAL_DIRECT_ENABLED_VALUES = frozenset({"1", "true", "on", "yes"})
28
- LOCAL_DIRECT_DOTENV_NAMES = frozenset({
29
- LOCAL_DIRECT_ENV,
30
- SKILL_API_BASE_URL_ENV,
31
- MARKETPLACE_API_KEY_ENV,
32
- })
33
24
  PROXY_ROUTE_ROOT = "/v1/ask-seoul/weather-risk"
34
25
  LOCATION_MAPPING_PATH = pathlib.Path(__file__).resolve().parents[1] / "references" / "admin-dong-place-map.json"
35
26
  LOCATION_MAPPING_VERSION = "kma_admin_dong_grid_20260325"
@@ -65,54 +56,14 @@ class _NoRedirect(HTTPRedirectHandler):
65
56
  @dataclass(frozen=True)
66
57
  class ApiConfig:
67
58
  base_url: str
68
- mode: str = "hosted_proxy"
69
- bearer_token: str | None = None
70
59
 
71
60
 
72
61
  def _json(value: Any) -> str:
73
62
  return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
74
63
 
75
64
 
76
- def _local_direct_config(values: dict[str, str]) -> ApiConfig:
77
- base_url = values.get(SKILL_API_BASE_URL_ENV, "").strip()
78
- bearer_token = values.get(MARKETPLACE_API_KEY_ENV, "").strip()
79
- if not base_url or base_url == "replace-me":
80
- raise SkillError("local_direct_not_configured", "local direct API base URL이 필요합니다.")
81
- if not bearer_token or bearer_token == "replace-me":
82
- raise SkillError("local_direct_not_configured", "local direct Marketplace API key가 필요합니다.")
83
-
84
- parsed = urlparse(base_url)
85
- local_http = parsed.scheme == "http" and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
86
- if (parsed.scheme != "https" and not local_http) or not parsed.netloc or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
87
- raise SkillError("invalid_local_direct_base_url", "local direct API base URL은 HTTPS origin이어야 합니다.")
88
- if parsed.username or parsed.password:
89
- raise SkillError("invalid_local_direct_base_url", "local direct API base URL에 사용자 정보는 포함할 수 없습니다.")
90
- return ApiConfig(base_url=base_url.rstrip("/"), mode="local_direct", bearer_token=bearer_token)
91
-
92
-
93
- def _current_directory_dotenv() -> dict[str, str]:
94
- path = pathlib.Path.cwd() / ".env"
95
- try:
96
- lines = path.read_text(encoding="utf-8").splitlines()
97
- except OSError:
98
- return {}
99
-
100
- values: dict[str, str] = {}
101
- for line in lines:
102
- stripped = line.strip()
103
- if not stripped or stripped.startswith("#") or "=" not in stripped:
104
- continue
105
- name, value = stripped.split("=", 1)
106
- name = name.strip()
107
- if name in LOCAL_DIRECT_DOTENV_NAMES:
108
- values[name] = value.strip().strip('"').strip("'")
109
- return values
110
-
111
-
112
65
  def _api_config(environ: dict[str, str] | None = None) -> ApiConfig:
113
- values = ({**_current_directory_dotenv(), **os.environ} if environ is None else environ)
114
- if values.get(LOCAL_DIRECT_ENV, "").strip().casefold() in LOCAL_DIRECT_ENABLED_VALUES:
115
- return _local_direct_config(values)
66
+ values = os.environ if environ is None else environ
116
67
  configured = values.get(PROXY_BASE_URL_ENV, "").strip()
117
68
  if configured.casefold() in PROXY_DISABLED_VALUES:
118
69
  raise SkillError("proxy_disabled", f"{PROXY_BASE_URL_ENV}가 비활성화되어 있습니다.")
@@ -159,8 +110,6 @@ def _request_json(config: ApiConfig, path: str, query: dict[str, str] | None = N
159
110
  "Accept": "application/json",
160
111
  "User-Agent": "k-skill-seoul-weather-risk/1",
161
112
  }
162
- if config.bearer_token:
163
- headers["Authorization"] = f"Bearer {config.bearer_token}"
164
113
  request = Request(url, headers=headers)
165
114
  try:
166
115
  with build_opener(_NoRedirect).open(request, timeout=15) as response:
@@ -399,22 +348,16 @@ def _validate_product_id(product_id: str) -> None:
399
348
 
400
349
 
401
350
  def _bundle(config: ApiConfig) -> dict[str, Any]:
402
- if config.mode == "local_direct":
403
- return _validate_bundle(_request_json(config, f"/skill/v1/bundles/{SKILL_BUNDLE_ID}"))
404
351
  return _validate_bundle(_request_json(config, f"{PROXY_ROUTE_ROOT}/bundle"))
405
352
 
406
353
 
407
354
  def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
408
355
  _validate_product_id(product_id)
409
- if config.mode == "local_direct":
410
- return _validate_product(_request_json(config, f"/skill/v1/products/{product_id}"), product_id)
411
356
  return _validate_product(_request_json(config, f"{PROXY_ROUTE_ROOT}/product"), product_id)
412
357
 
413
358
 
414
359
  def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
415
360
  _validate_product_id(product_id)
416
- if config.mode == "local_direct":
417
- return _validate_data(_request_json(config, f"/skill/v1/products/{product_id}/data", query), product_id, limit)
418
361
  return _validate_data(_request_json(config, f"{PROXY_ROUTE_ROOT}/data", query), product_id, limit)
419
362
 
420
363
 
@@ -428,6 +371,7 @@ def _parser() -> argparse.ArgumentParser:
428
371
  describe.add_argument("--product-id", required=True)
429
372
 
430
373
  query = commands.add_parser("query", help="제품 data page 조회")
374
+ query.add_argument("--fast", action="store_true", help="bundle/product metadata 확인을 생략하고 data만 조회")
431
375
  query.add_argument("--product-id", required=True)
432
376
  query.add_argument("--filter", action="append", default=[], metavar="COLUMN=VALUE")
433
377
  query.add_argument("--admin-dong", help="서울 행정동 이름")
@@ -446,11 +390,9 @@ def run(argv: list[str]) -> int:
446
390
  if args.command == "preflight":
447
391
  result = {
448
392
  "status": "ok",
449
- "mode": config.mode,
393
+ "mode": "hosted_proxy",
450
394
  "live_network": False,
451
- "user_api_key_required": config.mode == "local_direct",
452
- "proxy_base_url_configured": config.mode == "hosted_proxy",
453
- "local_direct_base_url_configured": config.mode == "local_direct",
395
+ "proxy_base_url_configured": True,
454
396
  }
455
397
  elif args.command == "catalog":
456
398
  result = _bundle(config)
@@ -460,8 +402,12 @@ def run(argv: list[str]) -> int:
460
402
  else:
461
403
  if not 1 <= args.limit <= 500:
462
404
  raise SkillError("invalid_limit", "limit은 1부터 500 사이여야 합니다.")
463
- _bundle(config)
464
- detail = _detail(config, args.product_id)
405
+ if args.fast and args.filter:
406
+ raise SkillError("fast_query_filter_unsupported", "--fast에서는 --filter를 사용할 수 없습니다.")
407
+ detail: dict[str, Any] | None = None
408
+ if not args.fast:
409
+ _bundle(config)
410
+ detail = _detail(config, args.product_id)
465
411
  filters = _filters(args.filter)
466
412
  if args.gu is not None and args.admin_dong is None:
467
413
  raise SkillError("invalid_location_input", "--gu는 --admin-dong과 함께 사용해야 합니다.")
@@ -477,10 +423,11 @@ def run(argv: list[str]) -> int:
477
423
  )
478
424
  resolved = _resolve_admin_dong(args.admin_dong, args.gu)
479
425
  filters["place_id"] = resolved["place_id"]
480
- allowed_columns = {column["name"] for column in detail["metadata"].get("columns", [])}
481
- unknown = sorted(set(filters) - allowed_columns)
482
- if unknown:
483
- raise SkillError("unknown_filter", f"공개 projection에 없는 필터입니다: {', '.join(unknown)}")
426
+ if detail is not None:
427
+ allowed_columns = {column["name"] for column in detail["metadata"].get("columns", [])}
428
+ unknown = sorted(set(filters) - allowed_columns)
429
+ if unknown:
430
+ raise SkillError("unknown_filter", f"공개 projection에 없는 필터입니다: {', '.join(unknown)}")
484
431
  request_query = {**filters, "limit": str(args.limit)}
485
432
  if args.from_value is not None:
486
433
  request_query["from"] = _time_bound(args.from_value, "from")
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "seoul-weather-risk",
3
- "description": "서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 기본 경로에는 사용자 API Key와 place_id 입력이 필요 없으며, 등록 전에는 명시적으로 local-direct 검증을 할 수 있다.",
3
+ "description": "서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 사용자 API Key와 place_id 입력은 필요하지 않다.",
4
4
  "profiles": [
5
5
  "proxy",
6
6
  "lookup"
7
7
  ],
8
- "frontmatter": "name: seoul-weather-risk\ndescription: 서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 기본 경로에는 사용자 API Key와 place_id 입력이 필요 없으며, 등록 전에는 명시적으로 local-direct 검증을 할 수 있다.\nlicense: MIT\nmetadata:\n category: public-data\n locale: ko-KR\n phase: live-client"
8
+ "frontmatter": "name: seoul-weather-risk\ndescription: 서울 행정동 이름을 정식명 우선·허용된 결정적 표기 별칭만으로 정규 place_id로 해석해 ASK 서울의 장소별 기상 위험 예상 시간대(weather_place_risk_window) 단일 제품을 hosted k-skill proxy에서 읽기 전용 조회한다. 사용자 API Key와 place_id 입력은 필요하지 않다.\nlicense: MIT\nmetadata:\n category: public-data\n locale: ko-KR\n phase: live-client"
9
9
  }