@nomadamas/k-skill 0.4.2 → 0.5.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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/skills/animal-pharmacy-search/instruction.md +150 -0
  3. package/skills/animal-pharmacy-search/scripts/animal_pharmacy_mcp.py +276 -0
  4. package/skills/animal-pharmacy-search/skill.json +8 -0
  5. package/skills/consumer-price-safety-search/instruction.md +30 -0
  6. package/skills/consumer-price-safety-search/skill.json +6 -0
  7. package/skills/coupang-product-search/instruction.md +59 -188
  8. package/skills/coupang-product-search/skill.json +6 -2
  9. package/skills/daiso-product-search/instruction.md +11 -1
  10. package/skills/fsc-corporate-info/instruction.md +2 -1
  11. package/skills/fsc-corporate-info/scripts/test_fsc_corporate_info.py +42 -0
  12. package/skills/g2b-sanctioned-supplier/instruction.md +2 -1
  13. package/skills/g2b-sanctioned-supplier/scripts/test_g2b_sanctioned_supplier.py +33 -0
  14. package/skills/government-support-survey/instruction.md +52 -0
  15. package/skills/government-support-survey/references/NOTICE.md +30 -0
  16. package/skills/government-support-survey/scripts/run_survey.py +71 -0
  17. package/skills/government-support-survey/skill.json +10 -0
  18. package/skills/kamis-food-price/instruction.md +66 -0
  19. package/skills/kamis-food-price/scripts/run_kamis.py +110 -0
  20. package/skills/kamis-food-price/skill.json +9 -0
  21. package/skills/korean-cinema-search/instruction.md +7 -1
  22. package/skills/market-kurly-search/instruction.md +9 -1
  23. package/skills/mofa-travel-safety/instruction.md +62 -0
  24. package/skills/mofa-travel-safety/scripts/run_mofa_travel_safety.py +57 -0
  25. package/skills/mofa-travel-safety/skill.json +9 -0
  26. package/skills/nts-tax-delinquency/instruction.md +2 -1
  27. package/skills/nts-tax-delinquency/scripts/nts_tax_delinquency.py +15 -1
  28. package/skills/nts-tax-delinquency/scripts/test_nts_tax_delinquency.py +32 -0
  29. package/skills/seoul-weather-risk/instruction.md +4 -1
  30. package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +99 -3
  31. package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
  32. package/templates/browser.md +1 -1
  33. package/skills/coupang-product-search/scripts/coupang_partners_mcp.py +0 -146
@@ -0,0 +1,66 @@
1
+ # KAMIS 농수축산물 가격 조회
2
+
3
+ ## What this skill does
4
+
5
+ 한국농수산식품유통공사 KAMIS의 `dailyPriceByCategoryList`를
6
+ `k-skill-proxy`의 좁은 read-only route로 호출한다. 농산물·축산물·수산물의
7
+ 부류별 도매/소매 가격과 전일, 1주일, 1개월, 1년, 평년 비교값을 반환한다.
8
+
9
+ 이 skill은 쇼핑몰 최저가나 투자 추천이 아니라 공식 유통가격 조회용이다.
10
+
11
+ ## Inputs
12
+
13
+ - `p_productclscode`: `01` 소매, `02` 도매. 기본 `01`
14
+ - `p_itemcategorycode`: `100` 식량작물, `200` 채소류, `300` 특용작물,
15
+ `400` 과일류, `500` 축산물, `600` 수산물. 기본 `100`
16
+ - `p_countycode`: 지역 코드(예: `1101` 서울). 생략하면 전체 지역
17
+ - `p_regday`: `YYYY-MM-DD`, 생략하면 최근 조사일
18
+ - `p_convert_kg_yn`: `Y` 또는 `N`. 기본 `N`
19
+
20
+ 사용자가 문서식 별칭인 `p_product_cls_code`, `p_country_code`,
21
+ `p_item_category_code`를 주어도 proxy가 실제 upstream 계약명으로 정규화한다.
22
+
23
+ ## Workflow
24
+
25
+ ```bash
26
+ BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
27
+ curl -fsS --get "$BASE/v1/kamis/food-price/daily-category" \
28
+ --data-urlencode "p_productclscode=01" \
29
+ --data-urlencode "p_itemcategorycode=200" \
30
+ --data-urlencode "p_countycode=1101" \
31
+ --data-urlencode "p_convert_kg_yn=N"
32
+ ```
33
+
34
+ 응답의 `items` 각 항목에서 `item_name`, `kind_name`, `rank`, `unit`,
35
+ `dpr1`(조회일), `dpr2`(1일 전), `dpr3`(1주일 전), `dpr5`(1개월 전),
36
+ `dpr6`(1년 전), `dpr7`(평년)을 확인한다. 가격 문자열의 쉼표와 `-`를
37
+ 숫자로 바꿀 때는 빈 값 여부를 먼저 보존한다.
38
+
39
+ ## Access path and credentials
40
+
41
+ 일반 사용자는 hosted `k-skill-proxy`를 사용하므로 KAMIS 키가 필요 없다.
42
+ proxy 운영자는 KAMIS에서 발급받은 `KAMIS_API_KEY`를 서버 runtime env에
43
+ 보관한다. upstream의 `p_cert_id`는 별도 API key가 아니라 요청자 ID
44
+ 파라미터이며, 현재 proxy는 live 계약에서 검증한 `TEST` 값을 사용한다.
45
+ 키를 URL, 로그, 저장소, 사용자 응답에 노출하지 않는다.
46
+
47
+ 공식 문서:
48
+ <https://www.kamis.or.kr/customer/reference/openapi_list.do?action=detail&boardno=1>
49
+
50
+ 실제 upstream 경로는 `xml.do`이지만 `p_returntype=json`을 사용한다. 이는
51
+ KAMIS의 공식 계약이며 URL을 `json.do`로 바꾸지 않는다.
52
+
53
+ ## Failure modes
54
+
55
+ - `400 bad_request`: 잘못된 날짜, 지역 코드, 부류 코드, 도소매 값
56
+ - `503 upstream_not_configured`: proxy runtime에 `KAMIS_API_KEY` 없음
57
+ - `400` 또는 `502 upstream_error`: KAMIS code `200`, `900`, 네트워크/상류 오류
58
+ - `items: []`, upstream code `001`: 해당 조건의 가격 없음
59
+ - KAMIS가 JSON 대신 점검 HTML/XML을 반환하면 `upstream_invalid_response`
60
+ - 가격은 조사 시점의 공식 데이터이며 구매 보장·법률 판단·투자 조언이 아니다
61
+
62
+ ## Done when
63
+
64
+ - 조회 조건과 `query`가 응답에 남아 있다.
65
+ - 각 가격 항목의 조회일과 비교 기간을 구분했다.
66
+ - 답변에 KAMIS 원문 출처와 조회 조건을 함께 적었다.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Read KAMIS prices through the hosted proxy, with an optional direct mode."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ DEFAULT_PROXY = "https://k-skill-proxy.nomadamas.org"
12
+ UPSTREAM = "https://www.kamis.or.kr/service/price/xml.do"
13
+
14
+
15
+ def secrets(path):
16
+ values = {}
17
+ try:
18
+ with open(path, encoding="utf-8") as stream:
19
+ for line in stream:
20
+ line = line.strip()
21
+ if line and not line.startswith("#") and "=" in line:
22
+ key, value = line.split("=", 1)
23
+ values[key.strip()] = value.strip().strip("\"'")
24
+ except OSError:
25
+ pass
26
+ return values
27
+
28
+
29
+ def query(args):
30
+ if args.product_class not in {"01", "02"}:
31
+ raise ValueError("--product-class must be 01 or 02")
32
+ if args.category not in {"100", "200", "300", "400", "500", "600"}:
33
+ raise ValueError("--category must be 100, 200, 300, 400, 500, or 600")
34
+ if args.convert_kg not in {"Y", "N"}:
35
+ raise ValueError("--convert-kg must be Y or N")
36
+ result = {
37
+ "p_productclscode": args.product_class,
38
+ "p_itemcategorycode": args.category,
39
+ "p_convert_kg_yn": args.convert_kg,
40
+ "p_returntype": "json",
41
+ }
42
+ if args.county:
43
+ if not args.county.isdigit() or len(args.county) != 4:
44
+ raise ValueError("--county must be a four-digit KAMIS code")
45
+ result["p_countycode"] = args.county
46
+ if args.date:
47
+ if len(args.date) != 10 or args.date[4] != "-" or args.date[7] != "-":
48
+ raise ValueError("--date must be YYYY-MM-DD")
49
+ result["p_regday"] = args.date
50
+ return result
51
+
52
+
53
+ def main(argv=None):
54
+ parser = argparse.ArgumentParser(description="KAMIS 농수축산물 가격 조회")
55
+ parser.add_argument("--product-class", default="01")
56
+ parser.add_argument("--category", default="100")
57
+ parser.add_argument("--county")
58
+ parser.add_argument("--date")
59
+ parser.add_argument("--convert-kg", default="N")
60
+ parser.add_argument("--text", action="store_true")
61
+ parser.add_argument("--dry-run", action="store_true")
62
+ parser.add_argument("--direct", action="store_true")
63
+ parser.add_argument("--proxy-base-url", default=os.getenv("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY))
64
+ parser.add_argument("--secrets-path", default=os.path.expanduser("~/.config/k-skill/secrets.env"))
65
+ args = parser.parse_args(argv)
66
+ key = None
67
+ try:
68
+ params = query(args)
69
+ except ValueError as error:
70
+ print(f"[error] {error}", file=sys.stderr)
71
+ return 2
72
+
73
+ if args.direct:
74
+ local = secrets(args.secrets_path)
75
+ key = os.getenv("KSKILL_KAMIS_API_KEY") or local.get("KSKILL_KAMIS_API_KEY")
76
+ if not key:
77
+ print("[error] --direct requires KSKILL_KAMIS_API_KEY", file=sys.stderr)
78
+ return 3
79
+ assert key is not None
80
+ params = {**params, "action": "dailyPriceByCategoryList", "p_cert_key": key, "p_cert_id": "TEST"}
81
+ url = f"{UPSTREAM}?{urllib.parse.urlencode(params)}"
82
+ else:
83
+ url = f"{args.proxy_base_url.rstrip('/')}/v1/kamis/food-price/daily-category?{urllib.parse.urlencode(params)}"
84
+
85
+ if args.dry_run:
86
+ display_params = {**params}
87
+ if args.direct:
88
+ display_params["p_cert_key"] = "<redacted>"
89
+ display_url = url.replace(key, "<redacted>") if args.direct and key is not None else url
90
+ print(json.dumps({"url": display_url, "query": display_params}, ensure_ascii=False, indent=2))
91
+ return 0
92
+
93
+ try:
94
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"accept": "application/json"}), timeout=30) as response:
95
+ payload = json.loads(response.read())
96
+ except (OSError, ValueError, urllib.error.HTTPError) as error:
97
+ print(f"[error] KAMIS request failed: {error}", file=sys.stderr)
98
+ return 4
99
+
100
+ if args.text:
101
+ items = payload.get("items", payload.get("data", {}).get("item", []))
102
+ for item in items:
103
+ print(f"{item.get('item_name', '?')} {item.get('kind_name', '')} {item.get('dpr1', '-')}{item.get('unit', '')}")
104
+ else:
105
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
106
+ return 0
107
+
108
+
109
+ if __name__ == "__main__":
110
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "kamis-food-price",
3
+ "description": "KAMIS 농수축산물 유통 Open API를 k-skill-proxy 경유로 호출해 부류별 도소매 가격과 전일·주간·월간·연간 비교값을 조회한다. 조회 전용.",
4
+ "profiles": [
5
+ "proxy",
6
+ "lookup"
7
+ ],
8
+ "frontmatter": "name: kamis-food-price\ndescription: KAMIS 농수축산물 유통 Open API를 k-skill-proxy 경유로 호출해 부류별 도소매 가격과 전일·주간·월간·연간 비교값을 조회한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: food\n locale: ko-KR\n phase: v1"
9
+ }
@@ -136,6 +136,12 @@ CGV는 시간표 중심으로 본다.
136
136
  npx --yes daiso get /api/cgv/timetable --keyword 강남 --playDate <YYYYMMDD> --json
137
137
  ```
138
138
 
139
+ CGV 공개 endpoint가 `503 CGV_UPSTREAM_UNAVAILABLE`을 반환하면 CGV 공식 웹사이트
140
+ (`https://www.cgv.co.kr`)를 브라우저 사용 스킬로 대체 조회한다. 이때도 공개 상영정보
141
+ 조회만 수행하고, 로그인·예매·결제는 진행하지 않는다. 공식 웹사이트에서 비정상 접속
142
+ 차단, CAPTCHA, 브라우저 검증 또는 유사한 접근 제한이 표시되면 이를 우회하지 말고
143
+ CGV 조회를 중단한다.
144
+
139
145
  메가박스와 롯데시네마는 잔여석 endpoint를 사용할 수 있다.
140
146
 
141
147
  ```bash
@@ -165,7 +171,7 @@ npx --yes daiso get /api/lottecinema/seats --keyword 월드타워 --playDate <YY
165
171
 
166
172
  ## Failure modes
167
173
 
168
- - public endpoint upstream 상태에 따라 간헐적인 5xx를 수 있다.
174
+ - CGV 공개 endpoint CGV 원본 서비스의 차단·접속 제한을 받으면 `503 CGV_UPSTREAM_UNAVAILABLE`을 반환할 수 있다. 이 응답은 skill 또는 CLI 입력 오류가 아니라 upstream 접근 실패이므로 CGV 공식 웹사이트를 브라우저 사용 스킬로 대체 조회한다. 공식 웹사이트에서도 차단, CAPTCHA, 브라우저 검증 또는 유사한 접근 제한이 표시되면 우회하지 말고 중단한다.
169
175
  - 지역 키워드가 넓으면 다른 지점이 섞일 수 있다.
170
176
  - 시간표와 잔여석은 시점에 따라 달라진다.
171
177
  - 일부 체인은 상영작, 시간표, 잔여석 endpoint의 입력값이 다르므로 theaterId, movieId가 있으면 그 값을 우선 사용한다.
@@ -27,7 +27,15 @@
27
27
 
28
28
  - 인터넷 연결
29
29
  - `node` 18+
30
- - 이 저장소의 `market-kurly-search` package 또는 동일 로직
30
+ - `market-kurly-search` npm package
31
+
32
+ 설치:
33
+
34
+ ```bash
35
+ npm install market-kurly-search
36
+ ```
37
+
38
+ 이 저장소에서 개발할 때는 루트에서 `npm install` 후 `packages/market-kurly-search`를 쓴다.
31
39
 
32
40
  ## Required inputs
33
41
 
@@ -0,0 +1,62 @@
1
+ # 외교부 해외안전·여행경보 조회
2
+
3
+ ## What this skill does
4
+
5
+ 외교부 `국가·지역별 여행경보 목록 조회(0404 대륙정보)` API를
6
+ `k-skill-proxy`의 좁은 read-only route로 조회한다. 국가명 또는 ISO 2자리
7
+ 코드로 공식 여행경보 단계, 지역 유형, 경보 내용, 작성일과 공식 지도 URL을
8
+ 확인한다.
9
+
10
+ 이 skill은 자체적인 안전/위험 점수나 여행 허가 판단을 만들지 않는다.
11
+ 응답된 외교부 공식 경보와 원문 링크를 사실 그대로 요약한다.
12
+
13
+ ## Inputs
14
+
15
+ - `country_iso_alp2`: ISO 2자리 국가코드(예: `RU`)
16
+ - `country_nm`: 한글 국가명(예: `러시아`)
17
+ - `page`: 기본 `1`
18
+ - `perPage`: 기본 `10`, 최대 `100`
19
+
20
+ 국가명과 ISO 코드는 동시에 주지 않는다.
21
+
22
+ ## Workflow
23
+
24
+ ```bash
25
+ BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
26
+ curl -fsS --get "$BASE/v1/mofa-travel-safety/travel-alerts" \
27
+ --data-urlencode "country_iso_alp2=RU" \
28
+ --data-urlencode "perPage=10"
29
+ ```
30
+
31
+ 응답 `items`의 `country_nm`, `country_iso_alp2`, `alarm_lvl`,
32
+ `region_ty`, `remark`, `written_dt`, `dang_map_download_url`을 원문 기준으로
33
+ 요약한다. `alarm_lvl` 숫자의 의미는 답변에서 임의로 재분류하지 말고
34
+ 외교부 원문/0404 설명을 함께 제시한다.
35
+
36
+ ## Access path and credentials
37
+
38
+ 일반 사용자는 hosted `k-skill-proxy`를 사용하므로 data.go.kr 키가 필요
39
+ 없다. proxy 운영자는 승인된 `DATA_GO_KR_API_KEY`를 서버 runtime env에
40
+ 보관한다. 키는 URL, 로그, 저장소, 사용자 응답에 노출하지 않는다.
41
+
42
+ 공식 API:
43
+ <https://www.data.go.kr/data/15095500/openapi.do>
44
+
45
+ 실제 upstream:
46
+ `https://apis.data.go.kr/1262000/TravelAlarmService0404/getTravelAlarm0404List`
47
+
48
+ ## Fallback and failure modes
49
+
50
+ - 먼저 proxy route를 사용하고, 실패하면 공식 data.go.kr API 문서와 0404
51
+ 원문 링크만 안내한다. 임의의 안전 판정을 대신하지 않는다.
52
+ - `400 bad_request`: 잘못된 ISO 코드, 국가명, 페이지 값
53
+ - `503 upstream_not_configured`: proxy runtime에 data.go.kr 키 없음
54
+ - `502 upstream_error`: 외교부/data.go.kr 인증·quota·상류 장애
55
+ - `upstream_invalid_response`: JSON 대신 XML/HTML 오류 응답
56
+ - `items: []`: 해당 조건에 맞는 국가/지역 기록 없음
57
+
58
+ ## Done when
59
+
60
+ - 국가명 또는 ISO 코드와 조회 시각을 답변에 적었다.
61
+ - `alarm_lvl`, `region_ty`, `remark`를 공식 필드명과 함께 제시했다.
62
+ - 원문 API 문서와 0404 공식 링크를 함께 제공했다.
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Read MOFA 0404 travel alerts through the hosted proxy."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ DEFAULT_PROXY = "https://k-skill-proxy.nomadamas.org"
12
+
13
+
14
+ def main(argv=None):
15
+ parser = argparse.ArgumentParser(description="외교부 국가별 여행경보 조회")
16
+ parser.add_argument("--country-iso")
17
+ parser.add_argument("--country-name")
18
+ parser.add_argument("--page", type=int, default=1)
19
+ parser.add_argument("--per-page", type=int, default=10)
20
+ parser.add_argument("--text", action="store_true")
21
+ parser.add_argument("--dry-run", action="store_true")
22
+ parser.add_argument("--proxy-base-url", default=os.getenv("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY))
23
+ args = parser.parse_args(argv)
24
+ if args.country_iso and args.country_name:
25
+ print("[error] use either --country-iso or --country-name", file=sys.stderr)
26
+ return 2
27
+ if not 1 <= args.page or not 1 <= args.per_page <= 100:
28
+ print("[error] invalid page or per-page", file=sys.stderr)
29
+ return 2
30
+ params = {"page": args.page, "perPage": args.per_page}
31
+ if args.country_iso:
32
+ if len(args.country_iso) != 2 or not args.country_iso.isalpha():
33
+ print("[error] --country-iso must be two letters", file=sys.stderr)
34
+ return 2
35
+ params["country_iso_alp2"] = args.country_iso.upper()
36
+ if args.country_name:
37
+ params["country_nm"] = args.country_name
38
+ url = f"{args.proxy_base_url.rstrip('/')}/v1/mofa-travel-safety/travel-alerts?{urllib.parse.urlencode(params)}"
39
+ if args.dry_run:
40
+ print(json.dumps({"url": url, "query": params}, ensure_ascii=False, indent=2))
41
+ return 0
42
+ try:
43
+ with urllib.request.urlopen(urllib.request.Request(url, headers={"accept": "application/json"}), timeout=30) as response:
44
+ payload = json.loads(response.read())
45
+ except (OSError, ValueError, urllib.error.HTTPError) as error:
46
+ print(f"[error] MOFA request failed: {error}", file=sys.stderr)
47
+ return 3
48
+ if args.text:
49
+ for item in payload.get("items", []):
50
+ print(f"{item.get('country_nm')} ({item.get('country_iso_alp2')}): level={item.get('alarm_lvl')} region={item.get('region_ty')}")
51
+ else:
52
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
53
+ return 0
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mofa-travel-safety",
3
+ "description": "외교부 0404 국가·지역별 여행경보 Open API를 k-skill-proxy 경유로 조회해 국가별 공식 경보 단계와 원문 정보를 제공한다. 조회 전용.",
4
+ "profiles": [
5
+ "proxy",
6
+ "lookup"
7
+ ],
8
+ "frontmatter": "name: mofa-travel-safety\ndescription: 외교부 0404 국가·지역별 여행경보 Open API를 k-skill-proxy 경유로 조회해 국가별 공식 경보 단계와 원문 정보를 제공한다. 조회 전용.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1"
9
+ }
@@ -47,7 +47,8 @@ npx -y @nomadamas/k-skill@0 exec nts-tax-delinquency scripts/nts_tax_delinquency
47
47
  ## Failure modes
48
48
 
49
49
  - `unavailable` + 안내: 상호 미입력, 네트워크 오류, 페이지 구조 변경 추정 — 수동 확인 URL 제공.
50
- - 0건: 명단 모두 매치 없음 (`match_count: 0`).
50
+ - `coverage`: 조회한 공개 명단 범위, 상호·법인명 문자열 대조 기준, 제외 범위, 0건의 의미, 조회시각(`checked_at`)을 구조화해 제공한다.
51
+ - 0건: 두 명단 모두 공개 명단에서 문자열 매치가 없음 (`match_count: 0`). 모든 국세 체납이 없다는 뜻은 아니다.
51
52
 
52
53
  ## Official surfaces
53
54
 
@@ -36,6 +36,18 @@ INDIV_COLUMNS = ("no", "공개년도", "성명", "연령", "상호", "직업(업
36
36
  IDENTITY_NOTE = ("명단공개 자료에는 사업자등록번호가 수록되지 않아 입력 사업자번호와의 "
37
37
  "동일성은 확인할 수 없다 — 상호·법인명 문자열 일치 후보의 공개 사실만 "
38
38
  "나열하며, 동명 상호일 가능성은 사용자가 판단한다.")
39
+ COVERAGE = {
40
+ "scope": "nts-high-amount-habitual-delinquent-disclosure",
41
+ "match_basis": "corporate-name-and-trade-name-string-match",
42
+ "exclusions": [
43
+ "명단공개 기준에 들지 않는 체납 및 비공개 체납",
44
+ "사업자등록번호 동일성 확인",
45
+ ],
46
+ "zero_result_meaning": (
47
+ "조회한 국세청 고액·상습체납자 공개 명단에서 법인명·상호 문자열 일치가 없다는 뜻이며, "
48
+ "모든 국세 체납이 없다는 뜻은 아니다."
49
+ ),
50
+ }
39
51
 
40
52
  _HEADING_MARKER = "고액상습체납자"
41
53
  _ZERO_MARKER = "조회된 데이터가 없습니다"
@@ -50,13 +62,15 @@ def _now_iso() -> str:
50
62
 
51
63
 
52
64
  def _envelope(status: str, *, result: dict | None = None, note: str | None = None) -> dict:
65
+ looked_up_at = _now_iso()
53
66
  return {
54
67
  "source": SOURCE,
55
- "looked_up_at": _now_iso(),
68
+ "looked_up_at": looked_up_at,
56
69
  "status": status,
57
70
  "result": result,
58
71
  "origin": "unauthenticated-public",
59
72
  "note": note,
73
+ "coverage": {**COVERAGE, "checked_at": looked_up_at} if status == "ok" else None,
60
74
  }
61
75
 
62
76
 
@@ -0,0 +1,32 @@
1
+ import unittest
2
+ from unittest.mock import patch
3
+
4
+ import nts_tax_delinquency as subject
5
+
6
+
7
+ class CoverageTest(unittest.TestCase):
8
+ def test_zero_result_explains_disclosure_scope(self):
9
+ with patch.object(subject, "_search", side_effect=[[], []]):
10
+ response = subject.lookup("테스트상사")
11
+
12
+ self.assertEqual(response["status"], "ok")
13
+ self.assertEqual(response["result"]["corporate_list"]["match_count"], 0)
14
+ self.assertEqual(response["result"]["individual_list"]["match_count"], 0)
15
+ self.assertEqual(response["coverage"]["match_basis"], "corporate-name-and-trade-name-string-match")
16
+ self.assertTrue(response["coverage"]["zero_result_meaning"])
17
+ self.assertTrue(response["coverage"]["checked_at"])
18
+
19
+ def test_matched_result_keeps_same_coverage_contract(self):
20
+ corporate = [{"법인명": "테스트상사", "총체납액": "1억원"}]
21
+ individual = [{"상호": "테스트상사", "총체납액": "2억원"}]
22
+ with patch.object(subject, "_search", side_effect=[corporate, individual]):
23
+ response = subject.lookup("테스트상사")
24
+
25
+ self.assertEqual(response["result"]["corporate_list"]["matches"], corporate)
26
+ self.assertEqual(response["result"]["individual_list"]["matches"], individual)
27
+ self.assertEqual(response["coverage"]["scope"], "nts-high-amount-habitual-delinquent-disclosure")
28
+ self.assertIn("사업자등록번호 동일성 확인", response["coverage"]["exclusions"])
29
+
30
+
31
+ if __name__ == "__main__":
32
+ unittest.main()
@@ -51,6 +51,8 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
51
51
 
52
52
  `--filter`가 필요하거나 게시 계약을 점검해야 할 때는 `--fast`를 빼고 full-contract query를 사용한다. fast query가 `product_not_ready` 또는 계약 오류를 반환하면 fixture나 추정값으로 대체하지 말고 아래 진단 흐름을 수행한다.
53
53
 
54
+ `--from`/`--to`에 날짜만 넣으면 그날 `00:00:00`–`23:59:59`로 확장한다. ASK 서울 serving window가 자정부터 열려 있지 않아 `422 query_window_unavailable`이 오면 helper는 요청 구간과 `available_from_at`/`available_to_at`의 교집합으로 한 번만 재시도한다. 교집합이 없으면 그 에러의 available window를 보여주고 중단한다. 없는 시간대를 추정 데이터로 채우지 않는다.
55
+
54
56
  ### Contract diagnostics (only when needed)
55
57
 
56
58
  1. 환경 설정만 확인한다. 이 명령은 네트워크를 호출하지 않는다.
@@ -99,6 +101,7 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
99
101
  - `location_mapping_invalid`: bundled 행정동 reference의 버전·스키마·행 수 계약 오류
100
102
  - `proxy_disabled`, `invalid_proxy_base_url`: proxy 환경 설정 오류
101
103
  - `unauthorized`/`api_key_missing`(401), `forbidden`/`api_key_forbidden`(403), `unknown_product`(404)
102
- - `cursor_expired`(409), `rate_limited`(429), `product_not_ready`(503)
104
+ - `cursor_expired`(409), `query_window_unavailable`(422), `rate_limited`(429), `product_not_ready`(503)
105
+ - `query_window_unavailable`: 요청한 `--from`/`--to`가 현재 제공 가능한 예보 window와 겹치지 않음. `details.available_from_at`/`available_to_at`를 확인한다. 겹치는 구간이면 helper가 이미 한 번 재시도한 뒤의 결과다.
103
106
  - `upstream_not_configured`(503): proxy 운영 환경에 ASK Seoul 전용 서비스 키 또는 origin이 설정되지 않음
104
107
  - `response_contract_invalid`, `malformed_response`: 단일 제품 계약 또는 API 응답 계약 drift
@@ -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.