@nomadamas/k-skill 0.4.3 → 0.6.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.
@@ -33,9 +33,18 @@ STATUS_CODES = {
33
33
  403: "forbidden",
34
34
  404: "unknown_product",
35
35
  409: "cursor_expired",
36
+ 422: "query_window_unavailable",
36
37
  429: "rate_limited",
37
38
  503: "product_not_ready",
38
39
  }
40
+ WINDOW_INSTANT_LENGTH = len("YYYY-MM-DD HH:MM:SS")
41
+ QUERY_WINDOW_DETAIL_FIELDS = (
42
+ "requested_from_at",
43
+ "requested_to_at",
44
+ "available_from_at",
45
+ "available_to_at",
46
+ "publication_id",
47
+ )
39
48
 
40
49
 
41
50
  class SkillError(RuntimeError):
@@ -89,13 +98,20 @@ def _error_payload(raw: bytes) -> dict[str, Any]:
89
98
  def _problem_error(status: int, raw: bytes, headers: Any) -> SkillError:
90
99
  problem = _error_payload(raw)
91
100
  code = problem.get("code") if isinstance(problem.get("code"), str) else STATUS_CODES.get(status, "api_error")
92
- message = problem.get("detail") if isinstance(problem.get("detail"), str) else problem.get("title")
101
+ raw_detail = problem.get("detail")
102
+ message = raw_detail if isinstance(raw_detail, str) else problem.get("title")
93
103
  if not isinstance(message, str) or not message:
94
104
  message = f"ASK Seoul API가 HTTP {status} 응답을 반환했습니다."
95
- details = {"status": status}
105
+ details: dict[str, Any] = {"status": status}
96
106
  for name in ("type", "title", "product_id", "blockers", "request_id"):
97
107
  if name in problem:
98
108
  details[name] = problem[name]
109
+ if isinstance(raw_detail, dict):
110
+ details["detail"] = raw_detail
111
+ for name in QUERY_WINDOW_DETAIL_FIELDS:
112
+ value = raw_detail.get(name)
113
+ if isinstance(value, str) and value:
114
+ details[name] = value
99
115
  retry_after = headers.get("Retry-After") if headers else None
100
116
  if retry_after:
101
117
  details["retry_after"] = retry_after
@@ -238,6 +254,72 @@ def _time_bound(value: str, edge: str) -> str:
238
254
  return value
239
255
 
240
256
 
257
+ def _window_sort_key(value: str) -> str | None:
258
+ text = " ".join(value.strip().replace("T", " ", 1).split())
259
+ if len(text) < WINDOW_INSTANT_LENGTH:
260
+ return None
261
+ key = text[:WINDOW_INSTANT_LENGTH]
262
+ if (
263
+ key[4] == "-"
264
+ and key[7] == "-"
265
+ and key[10] == " "
266
+ and key[13] == ":"
267
+ and key[16] == ":"
268
+ and key[:4].isdigit()
269
+ and key[5:7].isdigit()
270
+ and key[8:10].isdigit()
271
+ and key[11:13].isdigit()
272
+ and key[14:16].isdigit()
273
+ and key[17:19].isdigit()
274
+ ):
275
+ return key
276
+ return None
277
+
278
+
279
+ def _intersect_query_window(
280
+ requested_from: str,
281
+ requested_to: str,
282
+ available_from: str,
283
+ available_to: str,
284
+ ) -> tuple[str, str] | None:
285
+ req_from = _window_sort_key(requested_from)
286
+ req_to = _window_sort_key(requested_to)
287
+ avail_from = _window_sort_key(available_from)
288
+ avail_to = _window_sort_key(available_to)
289
+ if req_from is None or req_to is None or avail_from is None or avail_to is None:
290
+ return None
291
+ clipped_from = max(req_from, avail_from)
292
+ clipped_to = min(req_to, avail_to)
293
+ if clipped_from > clipped_to:
294
+ return None
295
+ return clipped_from, clipped_to
296
+
297
+
298
+ def _query_window_bounds(query: dict[str, str], error: SkillError) -> tuple[str, str, str, str] | None:
299
+ if error.details.get("status") != 422:
300
+ return None
301
+ available_from = error.details.get("available_from_at")
302
+ available_to = error.details.get("available_to_at")
303
+ requested_from = error.details.get("requested_from_at") or query.get("from")
304
+ requested_to = error.details.get("requested_to_at") or query.get("to")
305
+ if not all(isinstance(value, str) and value for value in (requested_from, requested_to, available_from, available_to)):
306
+ return None
307
+ return requested_from, requested_to, available_from, available_to
308
+
309
+
310
+ def _retry_query_after_unavailable_window(query: dict[str, str], error: SkillError) -> dict[str, str] | None:
311
+ bounds = _query_window_bounds(query, error)
312
+ if bounds is None or query.get("cursor"):
313
+ return None
314
+ clipped = _intersect_query_window(*bounds)
315
+ if clipped is None:
316
+ return None
317
+ clipped_from, clipped_to = clipped
318
+ if query.get("from") == clipped_from and query.get("to") == clipped_to:
319
+ return None
320
+ return {**query, "from": clipped_from, "to": clipped_to}
321
+
322
+
241
323
  def _normalize_location_name(value: str) -> str:
242
324
  return " ".join(unicodedata.normalize("NFC", value).strip().split())
243
325
 
@@ -358,7 +440,21 @@ def _detail(config: ApiConfig, product_id: str) -> dict[str, Any]:
358
440
 
359
441
  def _data(config: ApiConfig, product_id: str, query: dict[str, str], limit: int) -> dict[str, Any]:
360
442
  _validate_product_id(product_id)
361
- return _validate_data(_request_json(config, f"{PROXY_ROUTE_ROOT}/data", query), product_id, limit)
443
+ try:
444
+ payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", query)
445
+ except SkillError as exc:
446
+ retry_query = _retry_query_after_unavailable_window(query, exc)
447
+ if retry_query is None:
448
+ bounds = _query_window_bounds(query, exc)
449
+ if bounds is not None and _intersect_query_window(*bounds) is None:
450
+ raise SkillError(
451
+ "query_window_unavailable",
452
+ "요청한 조회 구간이 현재 제공 가능한 예보 window와 겹치지 않습니다.",
453
+ exc.details,
454
+ ) from exc
455
+ raise
456
+ payload = _request_json(config, f"{PROXY_ROUTE_ROOT}/data", retry_query)
457
+ return _validate_data(payload, product_id, limit)
362
458
 
363
459
 
364
460
  def _parser() -> argparse.ArgumentParser:
@@ -1,4 +1,4 @@
1
1
  <!-- mode:dolshoi -->
2
2
  - Use the built-in browser tool backed by CloakBrowser first. Fall back to `k-skill-browser-runtime` providers only when CloakBrowser is unavailable.
3
3
  <!-- mode:generic -->
4
- - Use `k-skill-browser-runtime` (provider `auto`: BrowserOS CDP, then Aside CLI, then user-launched Chrome CDP) for logged-in or rendered-page automation. Do not launch or close the user's browser, and never solve CAPTCHA, identity proofing, or e-signature flows.
4
+ - Use `k-skill-browser-runtime` (provider `auto`) for logged-in or rendered-page automation. On macOS it tries Aside Browser first, then BrowserOS CDP, then user-launched Chrome/Chromium CDP; on other platforms it tries BrowserOS CDP, then Aside Browser, then user-launched Chrome/Chromium CDP. Do not launch or close the user's browser, and never solve CAPTCHA, identity proofing, or e-signature flows.