instruction.md
# 나라장터 부정당제재업체정보 조회
## What this skill does
공공데이터포털의 **조달청 나라장터 사용자정보 서비스**(data.go.kr 15129466, `getUnptRsttCorpInfo02`)를 `k-skill-proxy` 경유로 호출해, 사업자등록번호 정확 일치(`inqryDiv=1`)로 **조회시점 현재 유효한** 부정당제재를 조회한다.
- 반환: 제재 시작/종료일자, 제재기관명, 계약법구분, 제재근거법률 등 upstream 필드 원문
- 감사 필드: `lookup_status`, `coverage.checked_at`, 고정된 `source.query`, 그리고 성공한 upstream JSON 원문 `upstream_response`
- `lookup_status`는 `active_sanctions_found`, `no_active_sanctions`, `lookup_failed` 중 하나다. `no_active_sanctions`는 성공한 0건이며 조회 실패와 다르다.
## Coverage boundary
upstream 명세상 다음은 **제공되지 않는다** — 과거 이력 조회가 아니다.
- 조회시점에 제재만료·해제된 건
- 나라장터 미등록업체·개인에 대한 제재
만료 이력까지 보려면 나라장터(<https://www.g2b.go.kr>)에서 수동 확인이 필요하다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. upstream 사실 + 출처 + 적용범위 한계만 담는다.
## 참가자격 판단 경계
이 결과만으로 특정 나라장터 공고의 참가 가능/불가를 판정하지 않는다. 참가자격은 공고별 원문에서 지역, 면허·업종 코드, 실적, 등록·인증의 필수 여부와 기준일을 별도로 대조해야 한다. 검색 결과나 제재 없음만으로 참가 가능하다고 답하지 않는다. 정정공고가 있으면 저장해 둔 이전 내용이 아니라 최종 공고 원문 기준으로 다시 확인한다.
## When to use
- "이 회사 입찰 제재(부정당제재) 이력 있어?"
- "거래/계약 전에 부정당업자 제재 여부 확인해줘"
## Prerequisites
- 인터넷 연결, `python3`
- `scripts/g2b_sanctioned_supplier.py` helper
- hosted/self-host `k-skill-proxy`의 `/v1/g2b/sanctioned-supplier` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `조달청_나라장터 사용자정보 서비스`(부정당제재업체정보조회 포함) 활용신청이 되어 있어야 한다.
## Inputs
- `--bizno`: 사업자등록번호 10자리(하이픈 허용) — 필수
## CLI examples
```bash
npx -y @nomadamas/k-skill@0 exec g2b-sanctioned-supplier scripts/g2b_sanctioned_supplier.py -- --bizno 124-81-00998
```
## Failure modes
- `lookup_status = active_sanctions_found`: 조회 성공, 현재 유효 제재 1건 이상.
- `lookup_status = no_active_sanctions`: 조회 성공, 현재 유효 제재 0건. 만료·해제된 과거 제재가 없다는 뜻은 아니다.
- `lookup_status = lookup_failed`: upstream timeout/error/권한 거절 등으로 조회 결과를 확인하지 못함. 이를 제재 없음으로 해석하지 않는다.
- 성공 응답은 `upstream_response`에 원본 JSON을 보존하고 `coverage.checked_at`에 조회시각을 남긴다.
- `400 bad_request`: 사업자번호가 10자리가 아님.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
- `502 upstream_forbidden`: 프록시 키가 15129466에 활용신청되지 않음.
- `coverage`: 현재 유효 제재 범위, 사업자번호 정확 일치 기준, 과거·미등록 제외 범위, 0건의 의미, 조회시각(`checked_at`)을 구조화해 제공한다.
## Official surfaces
- 공공데이터포털: <https://www.data.go.kr/data/15129466/openapi.do>
- upstream: `https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02`
- 수동 대조: 나라장터 <https://www.g2b.go.kr>
- 프록시 route: `GET /v1/g2b/sanctioned-supplier`
scripts/g2b_sanctioned_supplier.py
"""Procurement (나라장터) sanctioned-supplier lookup via k-skill-proxy.
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
query and reads the structured response. No user secret is required.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
PROXY_DOWN_MSG = "설정된 k-skill-proxy 서버가 응답하지 않습니다. 잠시 후 재시도하거나 운영자에게 문의하세요."
PROXY_KEY_NOT_CONFIGURED_MSG = "k-skill-proxy에 필요한 API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요."
ROUTE = "/v1/g2b/sanctioned-supplier"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None, payload: dict[str, Any] | None = None):
super().__init__(message)
self.status_code = status_code
self.payload = payload
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_bizno(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(bizno)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as error:
raise ApiError("g2b sanction proxy returned invalid JSON.") from error
if not isinstance(payload, dict):
raise ApiError("g2b sanction proxy returned a non-object JSON payload.")
return payload
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if error.code == 503 and isinstance(payload, dict) and payload.get("error") == "upstream_not_configured":
raise ApiError(PROXY_KEY_NOT_CONFIGURED_MSG, status_code=error.code, payload=payload) from error
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code, payload=payload) from error
raise ApiError(f"g2b sanction proxy request failed with HTTP {error.code}", status_code=error.code) from error
except urllib.error.URLError as error:
raise ApiError(f"{PROXY_DOWN_MSG} (상세: {error.reason})") from error
def query_sanctions(bizno: str, *, base_url: str | None = None,
read_json: Any = read_json_response) -> dict[str, Any]:
normalized = normalize_bizno(bizno)
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode({'bizno': normalized})}"
request = urllib.request.Request(url, headers={
"Accept": "application/json",
"User-Agent": "k-skill-g2b-sanctioned-supplier/1.0",
}, method="GET")
return read_json(request)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="나라장터 부정당제재업체정보 조회 (k-skill-proxy 경유)")
parser.add_argument("--bizno", required=True, help="사업자등록번호 10자리(하이픈 허용)")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = query_sanctions(args.bizno, base_url=args.proxy_base_url)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
output = {"error": str(error)}
if isinstance(error, ApiError):
output = {"lookup_status": "lookup_failed", **(error.payload or {}), "error": str(error)}
print(json.dumps(output, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
scripts/test_g2b_sanctioned_supplier.py
import contextlib
import io
import unittest
import g2b_sanctioned_supplier as subject
class CoveragePassthroughTest(unittest.TestCase):
def test_zero_result_keeps_proxy_coverage(self):
payload = {
"total_count": 0,
"active_sanctions": [],
"coverage": {"scope": "currently-effective-g2b-sanctions"},
}
response = subject.query_sanctions("123-45-67890", read_json=lambda _request: payload)
self.assertIs(response, payload)
self.assertEqual(response["coverage"]["scope"], "currently-effective-g2b-sanctions")
def test_matched_result_keeps_proxy_coverage(self):
payload = {
"total_count": 1,
"active_sanctions": [{"bizno": "1234567890"}],
"coverage": {"match_basis": "exact-business-number"},
}
response = subject.query_sanctions("1234567890", read_json=lambda _request: payload)
self.assertIs(response, payload)
self.assertEqual(response["coverage"]["match_basis"], "exact-business-number")
def test_main_preserves_lookup_failure_status(self):
original = subject.query_sanctions
subject.query_sanctions = lambda *_args, **_kwargs: (_ for _ in ()).throw(
subject.ApiError(
"proxy unavailable",
status_code=502,
payload={"lookup_status": "lookup_failed", "error": "upstream_error"},
)
)
stderr = io.StringIO()
try:
with contextlib.redirect_stderr(stderr):
exit_code = subject.main(["--bizno", "1234567890"])
finally:
subject.query_sanctions = original
self.assertEqual(exit_code, 1)
self.assertEqual(subject.json.loads(stderr.getvalue())["lookup_status"], "lookup_failed")
if __name__ == "__main__":
unittest.main()
skill.json
{
"name": "g2b-sanctioned-supplier",
"description": "조달청 나라장터 부정당제재업체정보를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업자등록번호 정확 일치로 조회시점 현재 유효한 입찰참가자격 제한(부정당제재)의 기간·제재기관·근거법률을 확인한다.",
"profiles": [
"proxy",
"lookup"
],
"frontmatter": "name: g2b-sanctioned-supplier\ndescription: 조달청 나라장터 부정당제재업체정보를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업자등록번호 정확 일치로 조회시점 현재 유효한 입찰참가자격 제한(부정당제재)의 기간·제재기관·근거법률을 확인한다.\nlicense: MIT\nmetadata:\n category: business\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: g2b-sanctioned-supplier
description: 조달청 나라장터 부정당제재업체정보를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업자등록번호 정확 일치로 조회시점 현재 유효한 입찰참가자격 제한(부정당제재)의 기간·제재기관·근거법률을 확인한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# g2b-sanctioned-supplier
<!-- 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 g2b-sanctioned-supplier
```
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 g2b-sanctioned-supplier
```
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/g2b-sanctioned-supplier/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.