instruction.md
# KAMIS 농수축산물 가격 조회
## What this skill does
한국농수산식품유통공사 KAMIS의 `dailyPriceByCategoryList`를
`k-skill-proxy`의 좁은 read-only route로 호출한다. 농산물·축산물·수산물의
부류별 도매/소매 가격과 전일, 1주일, 1개월, 1년, 평년 비교값을 반환한다.
이 skill은 쇼핑몰 최저가나 투자 추천이 아니라 공식 유통가격 조회용이다.
## Inputs
- `p_product_cls_code`: `01` 소매, `02` 도매. 기본 `01`
- `p_item_category_code`: `100` 식량작물, `200` 채소류, `300` 특용작물,
`400` 과일류, `500` 축산물, `600` 수산물. 기본 `100`
- `p_country_code`: 지역 코드(예: `1101` 서울). 생략하면 전체 지역
- `p_regday`: `YYYY-MM-DD`, 생략하면 최근 조사일
- `p_convert_kg_yn`: `Y` 또는 `N`. 기본 `N`
`p_regday`를 생략하면 proxy가 KST(`Asia/Seoul`) 기준 오늘부터 하루씩
되짚어(최대 7일) `items`가 있는 조사일을 찾고, 응답 `query.p_regday`에
실제 사용한 조사일을 담아 반환한다. 명시한 `p_regday`는 되짚지 않고
그대로 조회한다.
공식 계약 이름은 underscored 형식이며, 구식 별칭 `p_productclscode`,
`p_countycode`, `p_itemcategorycode`도 계속 받는다. 이름이 겹치면 공식
underscored 값이 우선하고, 빈 값은 무시하고 별칭으로 되돌아간다.
## Workflow
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "$BASE/v1/kamis/food-price/daily-category" \
--data-urlencode "p_product_cls_code=01" \
--data-urlencode "p_item_category_code=200" \
--data-urlencode "p_country_code=1101" \
--data-urlencode "p_convert_kg_yn=N"
```
응답의 `items` 각 항목에서 `item_name`, `kind_name`, `rank`, `unit`,
`dpr1`(조회일), `dpr2`(1일 전), `dpr3`(1주일 전), `dpr5`(1개월 전),
`dpr6`(1년 전), `dpr7`(평년)을 확인한다. 가격 문자열의 쉼표와 `-`를
숫자로 바꿀 때는 빈 값 여부를 먼저 보존한다.
## Access path and credentials
일반 사용자는 hosted `k-skill-proxy`를 사용하므로 KAMIS 키가 필요 없다.
proxy 운영자는 KAMIS에서 발급받은 `KAMIS_API_KEY`를 서버 runtime env에
보관한다. upstream의 `p_cert_id`는 별도 API key가 아니라 요청자 ID
파라미터이며, 현재 proxy는 live 계약에서 검증한 `TEST` 값을 사용한다.
키를 URL, 로그, 저장소, 사용자 응답에 노출하지 않는다.
공식 문서:
<https://www.kamis.or.kr/customer/reference/openapi_list.do?action=detail&boardno=1>
실제 upstream 경로는 `xml.do`이지만 `p_returntype=json`을 사용한다. 이는
KAMIS의 공식 계약이며 URL을 `json.do`로 바꾸지 않는다.
## Failure modes
- `400 bad_request`: 잘못된 날짜, 지역 코드, 부류 코드, 도소매 값
- `503 upstream_not_configured`: proxy runtime에 `KAMIS_API_KEY` 없음
- `400` 또는 `502 upstream_error`: KAMIS code `200`, `900`, 네트워크/상류 오류
- `502 upstream_category_mismatch`: upstream이 요청한 부류/도소매 필터를
적용하지 않아(응답 `condition`의 echo가 요청과 다름) 다른 데이터를 반환함.
Python helper는 같은 검증을 하며 `[error]`와 종료코드 `5`로 실패한다.
- `items: []`, upstream code `001`: 해당 조건의 가격 없음
- KAMIS가 JSON 대신 점검 HTML/XML을 반환하면 `upstream_invalid_response`
- 가격은 조사 시점의 공식 데이터이며 구매 보장·법률 판단·투자 조언이 아니다
## Done when
- 조회 조건과 `query`가 응답에 남아 있다.
- 각 가격 항목의 조회일과 비교 기간을 구분했다.
- 답변에 KAMIS 원문 출처와 조회 조건을 함께 적었다.
scripts/run_kamis.py
#!/usr/bin/env python3
"""Read KAMIS prices through the hosted proxy, with an optional direct mode."""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_PROXY = "https://k-skill-proxy.nomadamas.org"
UPSTREAM = "https://www.kamis.or.kr/service/price/xml.do"
def secrets(path):
values = {}
try:
with open(path, encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip("\"'")
except OSError:
pass
return values
def query(args):
if args.product_class not in {"01", "02"}:
raise ValueError("--product-class must be 01 or 02")
if args.category not in {"100", "200", "300", "400", "500", "600"}:
raise ValueError("--category must be 100, 200, 300, 400, 500, or 600")
if args.convert_kg not in {"Y", "N"}:
raise ValueError("--convert-kg must be Y or N")
result = {
"p_product_cls_code": args.product_class,
"p_item_category_code": args.category,
"p_convert_kg_yn": args.convert_kg,
"p_returntype": "json",
}
if args.county:
if not args.county.isdigit() or len(args.county) != 4:
raise ValueError("--county must be a four-digit KAMIS code")
result["p_country_code"] = args.county
if args.date:
if len(args.date) != 10 or args.date[4] != "-" or args.date[7] != "-":
raise ValueError("--date must be YYYY-MM-DD")
result["p_regday"] = args.date
return result
def _echoed_filters(payload):
"""Return the filters echoed by the proxy (``query``) or upstream (``condition``)."""
query = payload.get("query")
if isinstance(query, dict):
return query
condition = payload.get("condition")
if isinstance(condition, list):
echo = condition[0] if condition else None
else:
echo = condition
return echo if isinstance(echo, dict) else None
def _response_items(payload):
"""Return the price item list from a proxy or direct KAMIS payload.
A no-data upstream reply sometimes encodes the body as a bare array such as
``{"data": ["001"]}``; that must be treated as "no items" rather than read
as a mapping.
"""
items = payload.get("items")
if isinstance(items, list):
return items
data = payload.get("data")
if isinstance(data, dict) and isinstance(data.get("item"), list):
return data["item"]
return []
def _upstream_error_code(payload):
"""Return the first non-success upstream/proxy error code, if any."""
codes = []
data = payload.get("data")
if isinstance(data, dict):
if data.get("error_code") is not None:
codes.append(str(data["error_code"]))
elif isinstance(data, list):
codes.extend(
str(entry)
for entry in data
if isinstance(entry, (str, int)) and not isinstance(entry, bool)
)
upstream = payload.get("upstream")
if isinstance(upstream, dict) and upstream.get("error_code") is not None:
codes.append(str(upstream["error_code"]))
for code in codes:
if code not in {"", "000", "001"}:
return code
return None
def _response_problem(payload, args):
"""Return a message when the payload cannot be trusted for the request.
Mirrors the proxy's fail-closed behavior: a present echo that contradicts
the requested filters is an error, and a non-success/non-empty error code is
reported instead of crashing on unexpected shapes.
"""
echo = _echoed_filters(payload)
if echo is not None:
checks = (
("p_item_category_code", args.category, echo.get("p_item_category_code") or echo.get("p_category_code")),
("p_product_cls_code", args.product_class, echo.get("p_product_cls_code") or echo.get("p_productclscode")),
)
for name, requested, applied in checks:
if applied is not None and str(applied).strip() != requested:
return f"KAMIS applied {name}={applied} but {requested} was requested"
code = _upstream_error_code(payload)
if code is not None:
return f"KAMIS returned upstream error code {code}"
return None
def main(argv=None):
parser = argparse.ArgumentParser(description="KAMIS 농수축산물 가격 조회")
parser.add_argument("--product-class", default="01")
parser.add_argument("--category", default="100")
parser.add_argument("--county")
parser.add_argument("--date")
parser.add_argument("--convert-kg", default="N")
parser.add_argument("--text", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--direct", action="store_true")
parser.add_argument("--proxy-base-url", default=os.getenv("KSKILL_PROXY_BASE_URL", DEFAULT_PROXY))
parser.add_argument("--secrets-path", default=os.path.expanduser("~/.config/k-skill/secrets.env"))
args = parser.parse_args(argv)
key = None
try:
params = query(args)
except ValueError as error:
print(f"[error] {error}", file=sys.stderr)
return 2
if args.direct:
local = secrets(args.secrets_path)
key = os.getenv("KSKILL_KAMIS_API_KEY") or local.get("KSKILL_KAMIS_API_KEY")
if not key:
print("[error] --direct requires KSKILL_KAMIS_API_KEY", file=sys.stderr)
return 3
assert key is not None
params = {**params, "action": "dailyPriceByCategoryList", "p_cert_key": key, "p_cert_id": "TEST"}
url = f"{UPSTREAM}?{urllib.parse.urlencode(params)}"
else:
url = f"{args.proxy_base_url.rstrip('/')}/v1/kamis/food-price/daily-category?{urllib.parse.urlencode(params)}"
if args.dry_run:
display_params = {**params}
if args.direct:
display_params["p_cert_key"] = "<redacted>"
display_url = url.replace(key, "<redacted>") if args.direct and key is not None else url
print(json.dumps({"url": display_url, "query": display_params}, ensure_ascii=False, indent=2))
return 0
try:
with urllib.request.urlopen(urllib.request.Request(url, headers={"accept": "application/json", "user-agent": "k-skill/kamis-food-price"}), timeout=30) as response:
payload = json.loads(response.read())
except (OSError, ValueError, urllib.error.HTTPError) as error:
print(f"[error] KAMIS request failed: {error}", file=sys.stderr)
return 4
items = _response_items(payload)
problem = _response_problem(payload, args)
if problem:
print(f"[error] {problem}", file=sys.stderr)
return 5
if args.text:
for item in items:
print(f"{item.get('item_name', '?')} {item.get('kind_name', '')} {item.get('dpr1', '-')}{item.get('unit', '')}")
else:
print(json.dumps(payload, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md
---
name: kamis-food-price
description: KAMIS 농수축산물 유통 Open API를 k-skill-proxy 경유로 호출해 부류별 도소매 가격과 전일·주간·월간·연간 비교값을 조회한다. 조회 전용.
license: MIT
metadata:
category: food
locale: ko-KR
phase: v1
---
# kamis-food-price
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct kamis-food-price
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files kamis-food-price
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/kamis-food-price/instruction.md.
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.