mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Merge dev and address PR review fixes
This commit is contained in:
commit
c8bb7f9f35
26 changed files with 983 additions and 291 deletions
5
.changeset/korean-law-proxy.md
Normal file
5
.changeset/korean-law-proxy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add hosted `korean-law` proxy routes (`/v1/korean-law/search`, `/v1/korean-law/detail`) that wrap the official 법제처 (open.law.go.kr) DRF `lawSearch.do`/`lawService.do` endpoints. The proxy injects the operator `LAW_OC` plus a browser `User-Agent`/`Referer` (the actual cause of upstream "사용자 정보 검증 실패" rejections) and retries empty/HTML maintenance responses, so the `korean-law-search` skill becomes proxy-first with no per-user key. Drops the unstable Beopmang fallback from the documented surface.
|
||||
1
.github/workflows/deploy-k-skill-proxy.yml
vendored
1
.github/workflows/deploy-k-skill-proxy.yml
vendored
|
|
@ -88,6 +88,7 @@ jobs:
|
|||
KOSIS_API_KEY=KOSIS_API_KEY:latest
|
||||
NAVER_SEARCH_CLIENT_ID=NAVER_SEARCH_CLIENT_ID:latest
|
||||
NAVER_SEARCH_CLIENT_SECRET=NAVER_SEARCH_CLIENT_SECRET:latest
|
||||
LAW_OC=LAW_OC:latest
|
||||
env_vars: |-
|
||||
KSKILL_PROXY_HOST=0.0.0.0
|
||||
KSKILL_PROXY_NAME=k-skill-proxy
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
|
||||
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
|
||||
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
|
||||
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호 하나로 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 한 번에 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
|
||||
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호를 중심으로, 상호·지역을 함께 주면 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
|
||||
| 국민연금 가입 사업장 조회 | `national-pension-workplace` | 사업장명으로 국민연금 가입자수·당월 고지금액·월별 추이 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md) |
|
||||
| 국세 체납 명단공개 검색 | `nts-tax-delinquency` | 상호·법인명으로 국세청 고액·상습체납자 명단공개 대조(무인증 공개 검색) | 불필요 | [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md) |
|
||||
| 금융위 기업기본정보 조회 | `fsc-corporate-info` | 법인명으로 대표자·설립일·업종 등 법인 개요 조회와 사업자번호 교차검증(공공데이터포털 API, 프록시 경유) | 불필요 | [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md) |
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ def _normalize_b_no(value: Any) -> str:
|
|||
return normalized
|
||||
|
||||
|
||||
def _unavailable(module_key: str, note: str) -> dict:
|
||||
label, skill_dir, _ = _SIBLINGS[module_key]
|
||||
return {"provider": label, "skill": skill_dir, "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "data": None, "note": note}
|
||||
|
||||
def _load(module_key: str) -> Any | None:
|
||||
"""단품 스킬 helper를 레포 레이아웃 기준 파일 경로로 로드. 없으면 None."""
|
||||
_, skill_dir, filename = _SIBLINGS[module_key]
|
||||
|
|
@ -62,10 +67,7 @@ def _load(module_key: str) -> Any | None:
|
|||
if spec is None or spec.loader is None:
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
return None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
|
|
@ -73,14 +75,20 @@ def _section(module_key: str, caller: Callable[[Any], dict]) -> dict:
|
|||
"""단품 helper 하나를 호출해 섹션 결과로 감싼다. 어떤 오류든 강등."""
|
||||
label, skill_dir, _ = _SIBLINGS[module_key]
|
||||
base = {"provider": label, "skill": skill_dir, "looked_up_at": _now_iso()}
|
||||
module = _load(module_key)
|
||||
try:
|
||||
module = _load(module_key)
|
||||
except Exception as err:
|
||||
return {**base, "status": "unavailable", "data": None,
|
||||
"note": f"단품 스킬 '{skill_dir}' helper import 실패({type(err).__name__}: {err})."}
|
||||
if module is None:
|
||||
return {**base, "status": "unavailable",
|
||||
return {**base, "status": "unavailable", "data": None,
|
||||
"note": f"단품 스킬 '{skill_dir}' helper를 찾지 못해 건너뜀 (개별 설치 시 함께 두세요)."}
|
||||
try:
|
||||
return {**base, "data": caller(module)}
|
||||
data = caller(module)
|
||||
status = "unavailable" if isinstance(data, dict) and (data.get("status") == "unavailable" or data.get("error")) else "ok"
|
||||
return {**base, "status": status, "data": data}
|
||||
except Exception as err: # 경계 계약: 한 항목 실패가 전체를 막지 않는다
|
||||
return {**base, "status": "unavailable", "note": f"조회 실패({type(err).__name__}: {err})."}
|
||||
return {**base, "status": "unavailable", "data": None, "note": f"조회 실패({type(err).__name__}: {err})."}
|
||||
|
||||
|
||||
def run(b_no: str | None, name: str | None = None, region: str | None = None,
|
||||
|
|
@ -93,38 +101,31 @@ def run(b_no: str | None, name: str | None = None, region: str | None = None,
|
|||
sections["nts_status"] = _section(
|
||||
"nts_status", lambda m: m.query_status([no], base_url=base_url))
|
||||
else:
|
||||
sections["nts_status"] = {"provider": _SIBLINGS["nts_status"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "note": "사업자등록번호가 없어 상태조회 생략."}
|
||||
sections["nts_status"] = _unavailable("nts_status", "사업자등록번호가 없어 상태조회 생략.")
|
||||
|
||||
sections["national_pension"] = _section(
|
||||
"national_pension",
|
||||
lambda m: m.query_workplace(name, no, base_url=base_url)) if name else \
|
||||
{"provider": _SIBLINGS["national_pension"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "note": "상호(--name)가 없어 국민연금 조회 생략."}
|
||||
_unavailable("national_pension", "상호(--name)가 없어 국민연금 조회 생략.")
|
||||
|
||||
sections["fsc_corp"] = _section(
|
||||
"fsc_corp",
|
||||
lambda m: m.query_corp_outline(name, no, base_url=base_url)) if name else \
|
||||
{"provider": _SIBLINGS["fsc_corp"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "note": "법인명(--name)이 없어 금융위 조회 생략."}
|
||||
_unavailable("fsc_corp", "법인명(--name)이 없어 금융위 조회 생략.")
|
||||
|
||||
sections["g2b_sanction"] = _section(
|
||||
"g2b_sanction", lambda m: m.query_sanctions(no, base_url=base_url)) if no else \
|
||||
{"provider": _SIBLINGS["g2b_sanction"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "note": "사업자등록번호가 없어 부정당제재 조회 생략."}
|
||||
_unavailable("g2b_sanction", "사업자등록번호가 없어 부정당제재 조회 생략.")
|
||||
|
||||
sections["tax_delinquency"] = _section(
|
||||
"tax_delinquency", lambda m: m.lookup(name)) if name else \
|
||||
{"provider": _SIBLINGS["tax_delinquency"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(), "note": "상호(--name)가 없어 체납 명단 조회 생략."}
|
||||
_unavailable("tax_delinquency", "상호(--name)가 없어 체납 명단 조회 생략.")
|
||||
|
||||
if name and region:
|
||||
sections["localdata"] = _section(
|
||||
"localdata", lambda m: m.lookup(name, region, industries))
|
||||
else:
|
||||
sections["localdata"] = {"provider": _SIBLINGS["localdata"][0], "status": "unavailable",
|
||||
"looked_up_at": _now_iso(),
|
||||
"note": "동네 사업장 인허가 조회는 상호(--name)와 지역(--region)이 함께 필요."}
|
||||
sections["localdata"] = _unavailable("localdata", "동네 사업장 인허가 조회는 상호(--name)와 지역(--region)이 함께 필요.")
|
||||
|
||||
return {
|
||||
"query": {"b_no": no, "name": name, "region": region, "industries": industries},
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ for s in \
|
|||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET; do
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC; do
|
||||
gcloud secrets add-iam-policy-binding "$s" \
|
||||
--project="$PROJECT_ID" \
|
||||
--member="serviceAccount:${RUNTIME_SA}" \
|
||||
|
|
@ -159,7 +159,7 @@ KEYS=(
|
|||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC
|
||||
)
|
||||
|
||||
set -a; source ~/.config/k-skill/secrets.env; set +a
|
||||
|
|
|
|||
|
|
@ -2,126 +2,101 @@
|
|||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- `korean-law-mcp` 로 법령명 검색
|
||||
- 특정 법령의 조문 본문 조회
|
||||
- 판례 / 유권해석 / 자치법규 검색
|
||||
- MCP 또는 CLI 경로 중 현재 환경에 맞는 방식 선택
|
||||
- 기존 경로 장애 시 `법망` fallback으로 이어가기
|
||||
- `k-skill-proxy` 로 법령명/조문/판례/유권해석/자치법규 검색
|
||||
- 검색 결과 식별자로 조문·판례 본문(상세) 조회
|
||||
- 별도 API key나 로컬 설치 없이 hosted proxy로 바로 사용
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
한국 법령 관련 검색/조회가 필요할 때는 **`korean-law-mcp`를 먼저 사용**합니다.
|
||||
기존 서비스가 동작하지 않을 때만 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 전환합니다.
|
||||
별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
|
||||
한국 법령 관련 검색/조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint로 처리합니다. 사용자 쪽 `LAW_OC` 가 불필요합니다. 별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
|
||||
|
||||
이 endpoint는 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 감싼 것이고, read-only 도구 표면 설계는 `chrisryugj/korean-law-mcp` 를 참고했습니다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- remote MCP endpoint를 쓸 MCP 클라이언트
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
- (선택) `KSKILL_PROXY_BASE_URL` — self-host proxy를 쓸 때만
|
||||
|
||||
무료 API key 발급처: `https://open.law.go.kr`
|
||||
사용자는 별도 API key를 준비할 필요가 없습니다. upstream `LAW_OC` 는 proxy 서버에서만 주입합니다. 무료 발급처(운영자용): `https://open.law.go.kr`
|
||||
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
## 기본 경로
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
|
||||
|
||||
## 지원 endpoint
|
||||
|
||||
### 검색/목록 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/search?target={target}&query={검색어}
|
||||
```
|
||||
|
||||
| target | 설명 |
|
||||
|---|---|
|
||||
| `law` | 현행법령 |
|
||||
| `eflaw` | 시행일 법령 |
|
||||
| `prec` | 판례 |
|
||||
| `detc` | 헌재결정례 |
|
||||
| `expc` | 법령해석례(유권해석) |
|
||||
| `admrul` | 행정규칙 |
|
||||
| `ordin` | 자치법규 |
|
||||
| `trty` | 조약 |
|
||||
|
||||
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원) 등. 활성 필터만 넘기고, 요약 전에 반환 메타데이터를 확인합니다.
|
||||
|
||||
### 본문/상세 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/detail?target={target}&ID={일련번호}
|
||||
```
|
||||
|
||||
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져옵니다. 조문 지정은 `JO`(예: `000200` = 제2조)로 넘깁니다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
# 법령명 검색
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=law' \
|
||||
--data-urlencode 'query=관세법'
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
```
|
||||
# 판례 검색
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'query=부당해고'
|
||||
|
||||
로컬 설치가 막히면 먼저 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 사용한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용한다.
|
||||
|
||||
## MCP 연결 예시
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"command": "korean-law-mcp",
|
||||
"env": {
|
||||
"LAW_OC": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
remote endpoint 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"url": "https://korean-law-mcp.fly.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
위 remote 예시는 upstream 문서 기준으로 사용자 `LAW_OC` 를 따로 넣지 않는다. 사용자 쪽에서 준비할 것은 `url` 등록뿐이다.
|
||||
|
||||
## fallback: 법망
|
||||
|
||||
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 `법망`을 사용한다.
|
||||
|
||||
### MCP fallback
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"beopmang": {
|
||||
"url": "https://api.beopmang.org/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### REST fallback 예시
|
||||
|
||||
```bash
|
||||
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
|
||||
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
|
||||
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
|
||||
# 판례 본문 조회
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'ID=228541'
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 질의가 법령/판례/행정해석/자치법규 중 어디에 가까운지 분류한다.
|
||||
2. 법령명만 찾으면 `search_law` 를 먼저 쓴다.
|
||||
3. 특정 조문이 필요하면 `search_law` 또는 `search_all` 로 식별자(`mst`)를 확인한 뒤 `get_law_text` 를 호출한다.
|
||||
4. 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
|
||||
5. 범주가 애매하면 `search_all` 로 시작한다.
|
||||
6. `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 막히면 `법망` fallback으로 전환한다.
|
||||
7. fallback 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
|
||||
2. 법령명만 찾으면 `target=law` 로 `search` 한다.
|
||||
3. 특정 조문이 필요하면 `search` 로 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 을 호출한다.
|
||||
4. 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
|
||||
5. 범주가 애매하면 `target=law` 부터 시작한다.
|
||||
6. 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
|
||||
|
||||
## CLI 예시
|
||||
## 실패 모드
|
||||
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
- `target` 이 없거나 허용되지 않은 값이면 400 응답
|
||||
- 검색어/식별자가 없으면 400 응답
|
||||
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
|
||||
- 법제처 API가 사용자 검증 실패를 반환하면 502 + `law_user_verification_failed` (운영자가 서버 OC/UA/Referer 점검)
|
||||
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
|
||||
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다.
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- `화관법` 같은 약칭은 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 번호가 헷갈리면 `get_law_text` 전에 법령 식별자부터 다시 확인한다.
|
||||
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보를 안내한다.
|
||||
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
|
||||
- 기존 `korean-law-mcp` 경로가 실패하면 `https://api.beopmang.org/mcp` 또는 `/api/v4/law?action=search` 경로를 fallback으로 쓴다.
|
||||
- `화관법` 같은 약칭은 `target=law` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 번호가 헷갈리면 `detail` 전에 법령 식별자부터 다시 확인한다.
|
||||
- 요약은 할 수 있지만 법률 자문처럼 단정적으로 결론을 내리지는 않는다.
|
||||
|
||||
## 라이브 확인 메모
|
||||
## 출처
|
||||
|
||||
2026-04-01 기준 smoke test 에서 아래 명령은 실제로 정상 동작했다.
|
||||
|
||||
- `korean-law list`
|
||||
- `korean-law help search_law`
|
||||
|
||||
즉, `korean-law-mcp` CLI 설치와 기본 명령 진입은 검증했다. 실제 법령 검색은 로컬 CLI/MCP 경로라면 `LAW_OC` 가 준비된 환경에서 바로 이어서 사용할 수 있고, remote MCP endpoint는 사용자 `LAW_OC` 없이 URL 등록만으로 붙일 수 있다. 기존 경로 장애 시에는 `법망` fallback을 사용할 수 있다.
|
||||
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- 공식 데이터 출처: 법제처 국가법령정보 공동활용 (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
|
||||
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요)
|
||||
|
|
|
|||
|
|
@ -129,19 +129,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill fine-dust-location
|
||||
```
|
||||
|
||||
`korean-law-search` 는 skill 설치 후 upstream CLI/MCP도 준비해야 한다.
|
||||
|
||||
- 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운다.
|
||||
- remote endpoint는 `LAW_OC` 없이 `url`만 등록한다.
|
||||
- 기존 `korean-law-mcp` 경로가 실패하면 `법망`(`https://api.beopmang.org`) fallback을 사용한다.
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
korean-law list
|
||||
```
|
||||
|
||||
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
|
||||
`korean-law-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `LAW_OC` 가 불필요하다. proxy의 `/v1/korean-law/search` · `/v1/korean-law/detail` endpoint가 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr`)를 감싸며, 설계는 `https://github.com/chrisryugj/korean-law-mcp` 를 참고했다. 운영자만 proxy 서버에 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`). 자세한 사용법은 [한국 법령 검색 가이드](features/korean-law-search.md)를 본다.
|
||||
|
||||
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
|
|||
- `KRX_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`LAW_OC` 는 `korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy`의 `/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy`의 `/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy`의 `/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY` 는 `--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
`LAW_OC` 는 법제처 Open API(`open.law.go.kr`)를 호출할 때 쓰는 표준 식별자다. 한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` 라우트가 `LAW_OC` 와 브라우저 User-Agent/Referer 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `LAW_OC` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy`의 `/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy`의 `/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy`의 `/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy`의 `/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY` 는 `--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY`, `LAW_OC` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 한국 법령 검색, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -44,9 +44,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
|
||||
|
||||
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC` 는 `korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp` 와 `korean-law list` 로 설치 상태를 확인한다.
|
||||
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
|
||||
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
|
||||
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
|
|
@ -80,8 +78,7 @@ bash scripts/check-setup.sh
|
|||
| 고속버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 KOBUS HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
|
||||
| 시외버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 티머니 HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
|
||||
| 자연휴양림 빈 객실 조회 | `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` |
|
||||
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
|
||||
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
|
||||
| 한국 법령 검색 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`) |
|
||||
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
|
||||
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
|
||||
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
|
||||
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
|
||||
- beopmang: https://api.beopmang.org
|
||||
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
|
||||
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
|
||||
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
|
||||
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ KSKILL_FORESTTRIP_ID=replace-me
|
|||
KSKILL_FORESTTRIP_PASSWORD=replace-me
|
||||
# 일반 KOSIS 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
|
||||
KSKILL_KOSIS_API_KEY=replace-me
|
||||
# 한국 법령 검색은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
|
||||
LAW_OC=replace-me
|
||||
KIPRIS_PLUS_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
|
|
|
|||
|
|
@ -47,7 +47,13 @@ def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | No
|
|||
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
try:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ApiError("fsc corp-outline proxy returned invalid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError("fsc corp-outline proxy returned a non-object JSON payload.")
|
||||
return payload
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
|
|
@ -69,8 +75,9 @@ def query_corp_outline(name: str, b_no: str | None = None, *, base_url: str | No
|
|||
params = {"name": name}
|
||||
if _text_or_none(b_no):
|
||||
digits = re.sub(r"\D", "", str(b_no))
|
||||
if digits:
|
||||
params["b_no"] = digits
|
||||
if not re.fullmatch(r"\d{10}", digits):
|
||||
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
|
||||
params["b_no"] = digits
|
||||
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/json",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ def normalize_bizno(value: Any) -> str:
|
|||
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
|
||||
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
|
||||
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
|
||||
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
|
|
@ -115,8 +115,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
|
||||
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
|
||||
- 자연휴양림 빈 객실 조회: `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD`
|
||||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 법령 검색: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`)
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: korean-law-search
|
||||
description: Use korean-law-mcp first for Korean law lookups, and fall back to Beopmang when the primary service is unavailable.
|
||||
description: Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy. Use when the user asks for Korean law/article/precedent lookups.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: legal
|
||||
|
|
@ -12,16 +12,12 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
한국 법령/조문/판례/유권해석/자치법규 조회가 필요할 때 기본 경로로 **`korean-law-mcp`를 먼저 사용**하고, 기존 서비스가 동작하지 않을 때는 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 이어간다.
|
||||
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-law/...` 로 요청해서 한국 법령/조문/판례/유권해석/자치법규를 조회한다. 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 기반으로 하며, 설계는 `chrisryugj/korean-law-mcp` 의 read-only 도구 표면을 참고했다.
|
||||
|
||||
- 법령명 검색: `search_law`
|
||||
- 조문 본문 조회: `get_law_text`
|
||||
- 판례 검색: `search_precedents`
|
||||
- 유권해석 검색: `search_interpretations`
|
||||
- 자치법규 검색: `search_ordinance`
|
||||
- 여러 카테고리가 섞인 검색: `search_all`
|
||||
사용자는 별도 API key(`LAW_OC`)나 로컬 CLI 설치가 필요 없다. `LAW_OC` 와 브라우저 User-Agent/Referer 주입은 proxy 서버에서만 처리한다.
|
||||
|
||||
이 스킬은 자체 npm/python 패키지를 만들지 않는다. 한국 법령 관련 조회는 기본적으로 `korean-law-mcp` 로 처리하고, 해당 경로가 막히거나 실패가 반복될 때만 승인된 fallback 표면인 `법망`을 사용한다.
|
||||
- 검색/목록: `GET /v1/korean-law/search`
|
||||
- 본문/상세: `GET /v1/korean-law/detail`
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -39,136 +35,102 @@ metadata:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- MCP 클라이언트에 remote endpoint를 등록할 수 있는 환경
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream `LAW_OC` 는 proxy 서버에서만 주입한다.
|
||||
|
||||
무료 API key: `https://open.law.go.kr`
|
||||
## Default path
|
||||
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
|
||||
## Supported endpoints
|
||||
|
||||
### 검색/목록 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/search?target={target}&query={검색어}
|
||||
```
|
||||
|
||||
`target` 은 read-only 법령정보 종류다.
|
||||
|
||||
| target | 설명 |
|
||||
|---|---|
|
||||
| `law` | 현행법령 |
|
||||
| `eflaw` | 시행일 법령 |
|
||||
| `elaw` | 영문법령 |
|
||||
| `prec` | 판례 |
|
||||
| `detc` | 헌재결정례 |
|
||||
| `expc` | 법령해석례(유권해석) |
|
||||
| `admrul` | 행정규칙 |
|
||||
| `ordin` | 자치법규 |
|
||||
| `trty` | 조약 |
|
||||
| `lstrm` | 법령용어 |
|
||||
|
||||
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원), `org`, `knd`, `gana`, `nw`, `efYd`, `ancYd`. 응답은 법제처 DRF JSON 그대로에 `proxy` 메타데이터만 덧붙인다. 요약 전에 반환 메타데이터를 먼저 확인한다.
|
||||
|
||||
### 본문/상세 조회
|
||||
|
||||
```
|
||||
GET /v1/korean-law/detail?target={target}&ID={일련번호}
|
||||
```
|
||||
|
||||
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져온다. 조문 지정은 `JO`(예: `000200` = 제2조), 언어는 `LANG` 로 넘긴다.
|
||||
|
||||
## Example requests
|
||||
|
||||
법령명 검색:
|
||||
|
||||
```bash
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=law' \
|
||||
--data-urlencode 'query=관세법'
|
||||
```
|
||||
|
||||
로컬 설치가 운영체제 정책이나 권한 때문에 막히면 먼저 `korean-law-mcp` 의 remote MCP endpoint(`https://korean-law-mcp.fly.dev/mcp`)를 사용한다. 그래도 기존 경로가 응답하지 않거나 서비스 장애로 조회가 막히면, 승인된 fallback 표면인 `법망` MCP/REST(`https://api.beopmang.org`)로 전환한다.
|
||||
|
||||
## MCP client setup
|
||||
|
||||
Claude Desktop / Cursor / Windsurf 같은 MCP 클라이언트에는 아래처럼 연결한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"command": "korean-law-mcp",
|
||||
"env": {
|
||||
"LAW_OC": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
설치가 막힌 환경에서는 remote endpoint를 사용한다. 이 upstream 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"korean-law": {
|
||||
"url": "https://korean-law-mcp.fly.dev/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback workflow (`법망`)
|
||||
|
||||
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 아래 fallback을 사용한다.
|
||||
|
||||
### 1. MCP fallback
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"beopmang": {
|
||||
"url": "https://api.beopmang.org/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. REST fallback
|
||||
판례 검색:
|
||||
|
||||
```bash
|
||||
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
|
||||
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
|
||||
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'query=부당해고'
|
||||
```
|
||||
|
||||
## CLI workflow
|
||||
|
||||
### 1. 법령명부터 찾기
|
||||
판례 본문 조회:
|
||||
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
```
|
||||
|
||||
### 2. 특정 조문 본문 조회
|
||||
|
||||
```bash
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
```
|
||||
|
||||
### 3. 판례 검색
|
||||
|
||||
```bash
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
|
||||
### 4. 자치법규 검색
|
||||
|
||||
```bash
|
||||
korean-law search_ordinance --query "서울특별시 청년 기본 조례"
|
||||
```
|
||||
|
||||
### 5. 애매하면 통합 검색
|
||||
|
||||
```bash
|
||||
korean-law search_all --query "개인정보 처리방침 행정해석"
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'ID=228541'
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 한국 법령 관련 요청은 **항상 `korean-law-mcp`를 먼저 사용**한다.
|
||||
- 기존 `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 실패하면 `법망`(`https://api.beopmang.org`)을 fallback으로 사용한다.
|
||||
- 약칭(`화관법`)이면 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`mst`)를 확인한 뒤 `get_law_text` 로 본문을 가져온다.
|
||||
- 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
|
||||
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보 방법을 짧게 안내하고, 임의의 크롤링/검색엔진 우회로 넘어가지 않는다.
|
||||
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
|
||||
- 한국 법령 관련 요청은 이 proxy endpoint로 처리한다. 별도 크롤러나 검색엔진 우회로 넘어가지 않는다.
|
||||
- 약칭(`화관법`)이면 `target=law` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 로 본문을 가져온다.
|
||||
- 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
|
||||
- 판례 본문이 필요하면 검색 결과의 판례 일련번호를 `detail?target=prec&ID=...` 로 이어서 조회한다.
|
||||
- 검색 결과가 0건이어도 "관련 규범이 없다"고 단정하지 말고 검색어·법원·사건번호·선고일자·출처명을 바꿔 다시 시도한다.
|
||||
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다(없는 본문을 지어내지 않는다).
|
||||
- 법적 판단이 필요한 경우 `검색 결과 요약`과 `원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `target` 이 없거나 허용되지 않은 값이면 400 응답
|
||||
- 검색어/식별자가 없으면 400 응답
|
||||
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
|
||||
- 법제처 API가 사용자 검증 실패(`사용자 정보 검증 실패`)를 반환하면 502 + `law_user_verification_failed` (서버 OC/UA/Referer 점검)
|
||||
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
|
||||
|
||||
## Done when
|
||||
|
||||
- 한국 법령 관련 질의에 대해 `korean-law-mcp` 사용 경로가 선택되었다.
|
||||
- 필요한 검색/조회 명령이 정해졌다.
|
||||
- 법령/조문/판례/유권해석/자치법규 중 맞는 도구로 결과를 조회했다.
|
||||
- 유권해석이면 `search_interpretations`, 자치법규면 `search_ordinance` 까지 명시적으로 연결했다.
|
||||
- 로컬 경로라면 `LAW_OC` 확보 방법을 정확한 변수 이름으로 안내했다.
|
||||
- remote endpoint라면 사용자 `LAW_OC` 없이 `url` 등록 상태를 확인했다.
|
||||
- 기존 경로 장애 시 `법망` fallback(MCP 또는 REST)으로 이어지는 안내가 포함되었다.
|
||||
- 한국 법령 관련 질의를 proxy endpoint로 라우팅했다.
|
||||
- 법령/조문은 `target=law` + 필요 시 `detail`, 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 맞는 종류를 조회했다.
|
||||
- 판례/조문 본문이 필요하면 식별자로 `detail` 본문까지 연결했다.
|
||||
- 결과를 요약하고 원문 출처(법제처 국가법령정보센터)를 함께 남겼다.
|
||||
|
||||
## Notes
|
||||
|
||||
- upstream: `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- fallback surface: `https://api.beopmang.org`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`)
|
||||
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
|
||||
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요). 무료 발급: `https://open.law.go.kr`
|
||||
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.
|
||||
|
|
|
|||
|
|
@ -47,7 +47,13 @@ def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | No
|
|||
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
try:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except json.JSONDecodeError as error:
|
||||
raise ApiError("national-pension proxy returned invalid JSON.") from error
|
||||
if not isinstance(payload, dict):
|
||||
raise ApiError("national-pension proxy returned a non-object JSON payload.")
|
||||
return payload
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
|
|
@ -69,8 +75,9 @@ def query_workplace(name: str, b_no: str | None = None, *, base_url: str | None
|
|||
params = {"name": name}
|
||||
if _text_or_none(b_no):
|
||||
digits = re.sub(r"\D", "", str(b_no))
|
||||
if digits:
|
||||
params["b_no"] = digits
|
||||
if not re.fullmatch(r"\d{10}", digits):
|
||||
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
|
||||
params["b_no"] = digits
|
||||
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode(params)}"
|
||||
request = urllib.request.Request(url, headers={
|
||||
"Accept": "application/json",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"build": "npm run build --workspaces --if-present",
|
||||
"build:manus-bundle": "node scripts/build-manus-bundle.js",
|
||||
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/korean-law.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/g2b-sanction.js && node --check src/fsc-corp.js && node --check src/national-pension.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/korean-law.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,21 @@ function trimOrNull(value) {
|
|||
function digitsOnly(value) {
|
||||
return String(value ?? "").replace(/[^0-9]/g, "");
|
||||
}
|
||||
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
|
||||
|
||||
function parseGatewayAuthError(text) {
|
||||
if (!text.includes("OpenAPI_ServiceResponse")) {
|
||||
return null;
|
||||
}
|
||||
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
|
||||
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "SERVICE ERROR";
|
||||
return AUTH_REASON_CODES.has(reasonCode) ? `${authMsg} (code ${reasonCode})` : null;
|
||||
}
|
||||
|
||||
function isAuthResultCode(code) {
|
||||
return AUTH_REASON_CODES.has(String(code ?? "").trim());
|
||||
}
|
||||
|
||||
|
||||
function normalizeFscCorpQuery(query = {}) {
|
||||
const corpNm = trimOrNull(query.corpNm ?? query.name ?? query.b_nm);
|
||||
|
|
@ -30,7 +45,11 @@ function normalizeFscCorpQuery(query = {}) {
|
|||
"Provide corpNm (corporate name). The FSC outline API cannot be queried by the 10-digit business number alone."
|
||||
);
|
||||
}
|
||||
const bnoDigits = digitsOnly(query.b_no ?? query.bno);
|
||||
const rawBno = trimOrNull(query.b_no ?? query.bno);
|
||||
const bnoDigits = rawBno ? digitsOnly(rawBno) : "";
|
||||
if (rawBno && !/^\d{10}$/.test(bnoDigits)) {
|
||||
throw new Error("Provide b_no as a 10-digit business registration number.");
|
||||
}
|
||||
return { corpNm, bno: bnoDigits || null };
|
||||
}
|
||||
|
||||
|
|
@ -82,12 +101,27 @@ async function fetchFscCorpOutline({ corpNm, bno = null, serviceKey, fetchImpl =
|
|||
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const gatewayAuthError = parseGatewayAuthError(text);
|
||||
if (gatewayAuthError) {
|
||||
return {
|
||||
error: "upstream_forbidden",
|
||||
message: `FSC upstream rejected the request (${gatewayAuthError}). The proxy key may not be approved for service 15043184.`,
|
||||
};
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(await response.text());
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return { error: "upstream_invalid_response", message: "FSC upstream did not return valid JSON." };
|
||||
}
|
||||
if (isAuthResultCode(payload?.response?.header?.resultCode)) {
|
||||
return {
|
||||
error: "upstream_forbidden",
|
||||
message: `FSC upstream rejected the request (${payload.response.header.resultMsg || "auth error"}). The proxy key may not be approved for service 15043184.`,
|
||||
};
|
||||
}
|
||||
|
||||
let items;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,21 @@ const G2B_SANCTION_URL =
|
|||
function digitsOnly(value) {
|
||||
return String(value ?? "").replace(/[^0-9]/g, "");
|
||||
}
|
||||
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
|
||||
|
||||
function parseGatewayAuthError(text) {
|
||||
if (!text.includes("OpenAPI_ServiceResponse")) {
|
||||
return null;
|
||||
}
|
||||
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
|
||||
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "SERVICE ERROR";
|
||||
return AUTH_REASON_CODES.has(reasonCode) ? `${authMsg} (code ${reasonCode})` : null;
|
||||
}
|
||||
|
||||
function isAuthResultCode(code) {
|
||||
return AUTH_REASON_CODES.has(String(code ?? "").trim());
|
||||
}
|
||||
|
||||
|
||||
function normalizeG2bSanctionQuery(query = {}) {
|
||||
const bizno = digitsOnly(query.bizno ?? query.b_no ?? query.bno);
|
||||
|
|
@ -73,12 +88,27 @@ async function fetchG2bSanctions({ bizno, serviceKey, fetchImpl = global.fetch }
|
|||
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const gatewayAuthError = parseGatewayAuthError(text);
|
||||
if (gatewayAuthError) {
|
||||
return {
|
||||
error: "upstream_forbidden",
|
||||
message: `Procurement upstream rejected the request (${gatewayAuthError}). The proxy key may not be approved for service 15129466.`,
|
||||
};
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(await response.text());
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return { error: "upstream_invalid_response", message: "Procurement upstream did not return valid JSON." };
|
||||
}
|
||||
if (isAuthResultCode(payload?.response?.header?.resultCode)) {
|
||||
return {
|
||||
error: "upstream_forbidden",
|
||||
message: `Procurement upstream rejected the request (${payload.response.header.resultMsg || "auth error"}). The proxy key may not be approved for service 15129466.`,
|
||||
};
|
||||
}
|
||||
|
||||
let extracted;
|
||||
try {
|
||||
|
|
|
|||
313
packages/k-skill-proxy/src/korean-law.js
Normal file
313
packages/k-skill-proxy/src/korean-law.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
// k-skill-proxy wrapper for the official 법제처 (Korea Ministry of Government
|
||||
// Legislation) Open API "공동활용" DRF endpoints.
|
||||
//
|
||||
// Design notes:
|
||||
// - Mirrors the read-only legal-info surface that chrisryugj/korean-law-mcp
|
||||
// wraps (https://github.com/chrisryugj/korean-law-mcp), but exposes it as a
|
||||
// hosted REST proxy so skills do not need a per-user OC key or a local CLI.
|
||||
// - The OC identifier is injected server-side from the LAW_OC secret. It is the
|
||||
// only credential the upstream needs.
|
||||
// - law.go.kr rejects requests that lack a browser User-Agent / Referer with a
|
||||
// "사용자 정보 검증에 실패" body even when the OC is valid. We always inject
|
||||
// both headers (overridable via LAW_USER_AGENT / LAW_REFERER).
|
||||
// - law.go.kr also intermittently answers 200 with an empty body or an HTML
|
||||
// maintenance page; we retry those as transient failures.
|
||||
// - Read-only: only lawSearch.do (list/search) and lawService.do (detail/body)
|
||||
// are reachable. No mutation surface exists in the upstream API.
|
||||
|
||||
const KOREAN_LAW_API_BASE_URL = "https://www.law.go.kr/DRF";
|
||||
const DEFAULT_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
const DEFAULT_REFERER = "https://www.law.go.kr/";
|
||||
const REQUEST_TIMEOUT_MS = 20000;
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_BACKOFF_MS = 300;
|
||||
|
||||
// Read-only legal-info targets we are willing to proxy.
|
||||
const ALLOWED_TARGETS = new Set([
|
||||
"law", // 현행법령
|
||||
"eflaw", // 시행일 법령
|
||||
"elaw", // 영문법령
|
||||
"prec", // 판례
|
||||
"detc", // 헌재결정례
|
||||
"expc", // 법령해석례 (유권해석)
|
||||
"admrul", // 행정규칙
|
||||
"ordin", // 자치법규
|
||||
"trty", // 조약
|
||||
"lstrm", // 법령용어
|
||||
"lsHstInf" // 법령 연혁
|
||||
]);
|
||||
|
||||
const ALLOWED_TYPES = new Set(["JSON", "XML", "HTML"]);
|
||||
|
||||
// Pass-through query params for lawSearch.do (list/search).
|
||||
const SEARCH_PASSTHROUGH_PARAMS = [
|
||||
"query",
|
||||
"search",
|
||||
"display",
|
||||
"page",
|
||||
"sort",
|
||||
"date",
|
||||
"prncYd",
|
||||
"nb",
|
||||
"datSrcNm",
|
||||
"curt",
|
||||
"org",
|
||||
"knd",
|
||||
"gana",
|
||||
"nw",
|
||||
"efYd",
|
||||
"ancYd"
|
||||
];
|
||||
|
||||
// Pass-through query params for lawService.do (detail/body).
|
||||
const DETAIL_PASSTHROUGH_PARAMS = ["ID", "MST", "LID", "LM", "JO", "LANG", "chrClsCd", "ancYnChk"];
|
||||
|
||||
function trimOrNull(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
function buildError({ message, statusCode, code }) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeTarget(query) {
|
||||
const target = trimOrNull(query.target);
|
||||
if (!target) {
|
||||
throw buildError({
|
||||
message: "target is required (e.g. law, prec, expc, admrul, ordin).",
|
||||
statusCode: 400,
|
||||
code: "bad_request"
|
||||
});
|
||||
}
|
||||
if (!ALLOWED_TARGETS.has(target)) {
|
||||
throw buildError({
|
||||
message: `Unsupported target "${target}". Allowed: ${[...ALLOWED_TARGETS].join(", ")}.`,
|
||||
statusCode: 400,
|
||||
code: "bad_request"
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function normalizeType(query) {
|
||||
const raw = trimOrNull(query.type);
|
||||
if (!raw) {
|
||||
return "JSON";
|
||||
}
|
||||
const upper = raw.toUpperCase();
|
||||
if (!ALLOWED_TYPES.has(upper)) {
|
||||
throw buildError({
|
||||
message: `Unsupported type "${raw}". Allowed: ${[...ALLOWED_TYPES].join(", ")}.`,
|
||||
statusCode: 400,
|
||||
code: "bad_request"
|
||||
});
|
||||
}
|
||||
return upper;
|
||||
}
|
||||
|
||||
function collectPassthrough(query, allowedKeys) {
|
||||
const params = {};
|
||||
for (const key of allowedKeys) {
|
||||
const value = trimOrNull(query[key]);
|
||||
if (value !== null) {
|
||||
params[key] = value;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function normalizeKoreanLawSearchQuery(query = {}) {
|
||||
const target = normalizeTarget(query);
|
||||
const type = normalizeType(query);
|
||||
const params = collectPassthrough(query, SEARCH_PASSTHROUGH_PARAMS);
|
||||
|
||||
if (!params.query && !params.search && !params.nb && !params.datSrcNm) {
|
||||
throw buildError({
|
||||
message: "A search query is required (provide query, nb, or datSrcNm).",
|
||||
statusCode: 400,
|
||||
code: "bad_request"
|
||||
});
|
||||
}
|
||||
|
||||
return { target, type, params };
|
||||
}
|
||||
|
||||
function normalizeKoreanLawDetailQuery(query = {}) {
|
||||
const target = normalizeTarget(query);
|
||||
const type = normalizeType(query);
|
||||
const params = collectPassthrough(query, DETAIL_PASSTHROUGH_PARAMS);
|
||||
|
||||
if (!params.ID && !params.MST && !params.LID) {
|
||||
throw buildError({
|
||||
message: "A detail identifier is required (provide ID, MST, or LID).",
|
||||
statusCode: 400,
|
||||
code: "bad_request"
|
||||
});
|
||||
}
|
||||
|
||||
return { target, type, params };
|
||||
}
|
||||
|
||||
function buildKoreanLawUrl({ endpoint, target, type, params, oc }) {
|
||||
const path = endpoint === "detail" ? "lawService.do" : "lawSearch.do";
|
||||
const url = new URL(`${KOREAN_LAW_API_BASE_URL}/${path}`);
|
||||
url.searchParams.set("OC", oc);
|
||||
url.searchParams.set("target", target);
|
||||
url.searchParams.set("type", type);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function looksLikeHtml(body, contentType) {
|
||||
if (contentType.includes("text/html")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*<(?:!doctype|html)\b/i.test(body);
|
||||
}
|
||||
|
||||
function isUserVerificationFailure(body) {
|
||||
return /사용자\s*정보\s*검증|검증에\s*실패|IP주소\s*및\s*도메인/.test(body);
|
||||
}
|
||||
|
||||
async function delay(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchKoreanLaw(url, { userAgent, referer, fetchImpl = global.fetch, sleep = delay, expectJson = true } = {}) {
|
||||
const headers = {
|
||||
"User-Agent": userAgent || DEFAULT_USER_AGENT,
|
||||
Referer: referer || DEFAULT_REFERER,
|
||||
Accept: expectJson ? "application/json, text/plain, */*" : "*/*"
|
||||
};
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
||||
});
|
||||
const body = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
const trimmed = body.trim();
|
||||
|
||||
if (!response.ok) {
|
||||
return { statusCode: response.status, contentType, body };
|
||||
}
|
||||
|
||||
const transientEmpty = trimmed === "";
|
||||
const transientHtml = expectJson && looksLikeHtml(trimmed, contentType);
|
||||
if (transientEmpty || transientHtml) {
|
||||
lastError = buildError({
|
||||
message: "law.go.kr returned an empty or HTML maintenance response.",
|
||||
statusCode: 502,
|
||||
code: "upstream_unstable"
|
||||
});
|
||||
} else {
|
||||
return { statusCode: 200, contentType, body };
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attempt < MAX_ATTEMPTS - 1) {
|
||||
await sleep(RETRY_BACKOFF_MS * (attempt + 1));
|
||||
}
|
||||
}
|
||||
|
||||
throw (
|
||||
lastError ||
|
||||
buildError({
|
||||
message: "law.go.kr request failed.",
|
||||
statusCode: 502,
|
||||
code: "upstream_error"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function proxyKoreanLawRequest({
|
||||
endpoint,
|
||||
normalized,
|
||||
oc,
|
||||
userAgent = null,
|
||||
referer = null,
|
||||
fetchImpl = global.fetch,
|
||||
sleep = delay
|
||||
}) {
|
||||
if (!oc) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: "upstream_not_configured",
|
||||
message: "LAW_OC is not configured on the proxy server."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const url = buildKoreanLawUrl({
|
||||
endpoint,
|
||||
target: normalized.target,
|
||||
type: normalized.type,
|
||||
params: normalized.params,
|
||||
oc
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await fetchKoreanLaw(url, {
|
||||
userAgent,
|
||||
referer,
|
||||
fetchImpl,
|
||||
sleep,
|
||||
expectJson: normalized.type === "JSON"
|
||||
});
|
||||
|
||||
if (result.statusCode >= 200 && result.statusCode < 300 && isUserVerificationFailure(result.body)) {
|
||||
return {
|
||||
statusCode: 502,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: "law_user_verification_failed",
|
||||
message:
|
||||
"law.go.kr rejected the proxy request (사용자 정보 검증 실패). Check LAW_OC and the LAW_USER_AGENT/LAW_REFERER headers on the proxy server."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
statusCode: error.statusCode && error.statusCode >= 400 ? error.statusCode : 502,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
KOREAN_LAW_API_BASE_URL,
|
||||
DEFAULT_USER_AGENT,
|
||||
DEFAULT_REFERER,
|
||||
ALLOWED_TARGETS,
|
||||
ALLOWED_TYPES,
|
||||
buildKoreanLawUrl,
|
||||
fetchKoreanLaw,
|
||||
isUserVerificationFailure,
|
||||
normalizeKoreanLawDetailQuery,
|
||||
normalizeKoreanLawSearchQuery,
|
||||
proxyKoreanLawRequest
|
||||
};
|
||||
Binary file not shown.
|
|
@ -45,6 +45,11 @@ const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-cod
|
|||
const { normalizeNationalPensionQuery, fetchNationalPensionWorkplace } = require("./national-pension");
|
||||
const { normalizeFscCorpQuery, fetchFscCorpOutline } = require("./fsc-corp");
|
||||
const { normalizeG2bSanctionQuery, fetchG2bSanctions } = require("./g2b-sanction");
|
||||
const {
|
||||
normalizeKoreanLawDetailQuery,
|
||||
normalizeKoreanLawSearchQuery,
|
||||
proxyKoreanLawRequest
|
||||
} = require("./korean-law");
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
|
||||
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
|
||||
|
|
@ -187,6 +192,9 @@ function buildConfig(env = process.env) {
|
|||
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
|
||||
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
|
||||
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
|
||||
lawOc: trimOrNull(env.LAW_OC),
|
||||
lawReferer: trimOrNull(env.LAW_REFERER),
|
||||
lawUserAgent: trimOrNull(env.LAW_USER_AGENT),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -1894,7 +1902,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
kstartupConfigured: Boolean(config.molitApiKey),
|
||||
nationalPensionConfigured: Boolean(config.molitApiKey),
|
||||
fscCorpConfigured: Boolean(config.molitApiKey),
|
||||
g2bSanctionConfigured: Boolean(config.molitApiKey)
|
||||
g2bSanctionConfigured: Boolean(config.molitApiKey),
|
||||
koreanLawConfigured: Boolean(config.lawOc)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -3413,8 +3422,15 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
};
|
||||
}
|
||||
|
||||
const keyedErrorStatus = {
|
||||
upstream_forbidden: 502,
|
||||
upstream_timeout: 504,
|
||||
upstream_invalid_response: 502,
|
||||
upstream_error: 502
|
||||
};
|
||||
|
||||
if (result && result.error) {
|
||||
reply.code(502);
|
||||
reply.code(keyedErrorStatus[result.error] || 502);
|
||||
return {
|
||||
...result,
|
||||
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
|
||||
|
|
@ -3602,6 +3618,97 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
reply
|
||||
}));
|
||||
|
||||
async function handleKoreanLawRoute({ endpoint, normalize, cacheRoute, request, reply }) {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalize(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 400);
|
||||
return {
|
||||
error: error.code || "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: cacheRoute,
|
||||
target: normalized.target,
|
||||
type: normalized.type,
|
||||
params: normalized.params
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
if (typeof cached === "object" && cached.body !== undefined) {
|
||||
reply.code(cached.statusCode);
|
||||
reply.header("content-type", cached.contentType);
|
||||
return cached.body;
|
||||
}
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: { hit: true, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxyKoreanLawRequest({
|
||||
endpoint,
|
||||
normalized,
|
||||
oc: config.lawOc,
|
||||
userAgent: config.lawUserAgent,
|
||||
referer: config.lawReferer
|
||||
});
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
||||
if (!upstream.contentType.includes("json")) {
|
||||
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
|
||||
cache.set(
|
||||
cacheKey,
|
||||
{ statusCode: upstream.statusCode, contentType: upstream.contentType, body: upstream.body },
|
||||
config.cacheTtlMs
|
||||
);
|
||||
}
|
||||
return upstream.body;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(upstream.body);
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
payload.proxy = {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
requested_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
app.get("/v1/korean-law/search", async (request, reply) =>
|
||||
handleKoreanLawRoute({
|
||||
endpoint: "search",
|
||||
normalize: normalizeKoreanLawSearchQuery,
|
||||
cacheRoute: "korean-law-search",
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/korean-law/detail", async (request, reply) =>
|
||||
handleKoreanLawRoute({
|
||||
endpoint: "detail",
|
||||
normalize: normalizeKoreanLawDetailQuery,
|
||||
cacheRoute: "korean-law-detail",
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
|
|
|
|||
208
packages/k-skill-proxy/test/korean-law.test.js
Normal file
208
packages/k-skill-proxy/test/korean-law.test.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
DEFAULT_REFERER,
|
||||
DEFAULT_USER_AGENT,
|
||||
buildKoreanLawUrl,
|
||||
fetchKoreanLaw,
|
||||
isUserVerificationFailure,
|
||||
normalizeKoreanLawDetailQuery,
|
||||
normalizeKoreanLawSearchQuery,
|
||||
proxyKoreanLawRequest
|
||||
} = require("../src/korean-law");
|
||||
|
||||
const noopSleep = async () => {};
|
||||
|
||||
function jsonResponse(body, { status = 200, contentType = "application/json; charset=utf-8" } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => (name.toLowerCase() === "content-type" ? contentType : null) },
|
||||
text: async () => (typeof body === "string" ? body : JSON.stringify(body))
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeKoreanLawSearchQuery requires a target", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ query: "관세법" }), /target is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery rejects an unsupported target", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "evil", query: "x" }), /Unsupported target/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery requires a search query", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law" }), /search query is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery keeps only allowlisted params and defaults type to JSON", () => {
|
||||
const normalized = normalizeKoreanLawSearchQuery({
|
||||
target: "prec",
|
||||
query: "부당해고",
|
||||
display: "5",
|
||||
curt: "대법원",
|
||||
evil: "drop-me"
|
||||
});
|
||||
|
||||
assert.equal(normalized.target, "prec");
|
||||
assert.equal(normalized.type, "JSON");
|
||||
assert.deepEqual(normalized.params, { query: "부당해고", display: "5", curt: "대법원" });
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawDetailQuery requires an identifier", () => {
|
||||
assert.throws(() => normalizeKoreanLawDetailQuery({ target: "prec" }), /detail identifier is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawDetailQuery accepts ID and passthrough params", () => {
|
||||
const normalized = normalizeKoreanLawDetailQuery({ target: "prec", ID: "228541", JO: "0002", evil: "x" });
|
||||
assert.deepEqual(normalized.params, { ID: "228541", JO: "0002" });
|
||||
});
|
||||
|
||||
test("normalizeType rejects unsupported types", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law", query: "x", type: "csv" }), /Unsupported type/);
|
||||
});
|
||||
|
||||
test("buildKoreanLawUrl injects OC, target, type and routes search vs detail", () => {
|
||||
const searchUrl = buildKoreanLawUrl({
|
||||
endpoint: "search",
|
||||
target: "prec",
|
||||
type: "JSON",
|
||||
params: { query: "부당해고" },
|
||||
oc: "secret-oc"
|
||||
});
|
||||
assert.match(searchUrl, /\/DRF\/lawSearch\.do\?/);
|
||||
assert.match(searchUrl, /OC=secret-oc/);
|
||||
assert.match(searchUrl, /target=prec/);
|
||||
assert.match(searchUrl, /type=JSON/);
|
||||
assert.match(searchUrl, /query=%EB%B6%80%EB%8B%B9%ED%95%B4%EA%B3%A0/);
|
||||
|
||||
const detailUrl = buildKoreanLawUrl({
|
||||
endpoint: "detail",
|
||||
target: "prec",
|
||||
type: "JSON",
|
||||
params: { ID: "228541" },
|
||||
oc: "secret-oc"
|
||||
});
|
||||
assert.match(detailUrl, /\/DRF\/lawService\.do\?/);
|
||||
assert.match(detailUrl, /ID=228541/);
|
||||
});
|
||||
|
||||
test("isUserVerificationFailure detects the law.go.kr rejection body", () => {
|
||||
assert.equal(isUserVerificationFailure('{"result":"사용자 정보 검증에 실패하였습니다."}'), true);
|
||||
assert.equal(isUserVerificationFailure('{"PrecSearch":{}}'), false);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw sends browser User-Agent and Referer headers", async () => {
|
||||
let sentHeaders = null;
|
||||
const fetchImpl = async (_url, options) => {
|
||||
sentHeaders = options.headers;
|
||||
return jsonResponse({ PrecSearch: { prec: [] } });
|
||||
};
|
||||
|
||||
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
|
||||
|
||||
assert.equal(sentHeaders["User-Agent"], DEFAULT_USER_AGENT);
|
||||
assert.equal(sentHeaders.Referer, DEFAULT_REFERER);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw honors custom User-Agent and Referer overrides", async () => {
|
||||
let sentHeaders = null;
|
||||
const fetchImpl = async (_url, options) => {
|
||||
sentHeaders = options.headers;
|
||||
return jsonResponse({ ok: true });
|
||||
};
|
||||
|
||||
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", {
|
||||
fetchImpl,
|
||||
sleep: noopSleep,
|
||||
userAgent: "custom-ua",
|
||||
referer: "https://example.test/"
|
||||
});
|
||||
|
||||
assert.equal(sentHeaders["User-Agent"], "custom-ua");
|
||||
assert.equal(sentHeaders.Referer, "https://example.test/");
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw retries empty/HTML responses then succeeds", async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return jsonResponse("", { contentType: "application/json" });
|
||||
}
|
||||
if (calls === 2) {
|
||||
return jsonResponse("<html><body>maintenance</body></html>", { contentType: "text/html" });
|
||||
}
|
||||
return jsonResponse({ LawSearch: { law: [{ id: "1" }] } });
|
||||
};
|
||||
|
||||
const result = await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
|
||||
assert.equal(calls, 3);
|
||||
assert.match(result.body, /LawSearch/);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw throws after exhausting retries on persistent empty bodies", async () => {
|
||||
const fetchImpl = async () => jsonResponse("", { contentType: "application/json" });
|
||||
await assert.rejects(
|
||||
() => fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep }),
|
||||
/empty or HTML/
|
||||
);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest returns 503 when LAW_OC is not configured", async () => {
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: null,
|
||||
sleep: noopSleep
|
||||
});
|
||||
assert.equal(result.statusCode, 503);
|
||||
assert.match(result.body, /upstream_not_configured/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest passes the OC through to the upstream URL", async () => {
|
||||
let calledUrl = null;
|
||||
const fetchImpl = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return jsonResponse({ LawSearch: { law: [] } });
|
||||
};
|
||||
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.match(calledUrl, /OC=secret-oc/);
|
||||
assert.match(calledUrl, /\/lawSearch\.do\?/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest maps a user-verification body to a 502 error", async () => {
|
||||
const fetchImpl = async () => jsonResponse({ result: "사용자 정보 검증에 실패하였습니다." });
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 502);
|
||||
assert.match(result.body, /law_user_verification_failed/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest surfaces upstream non-2xx responses verbatim", async () => {
|
||||
const fetchImpl = async () => jsonResponse("server error", { status: 500, contentType: "text/plain" });
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "detail",
|
||||
normalized: { target: "prec", type: "JSON", params: { ID: "228541" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 500);
|
||||
});
|
||||
|
|
@ -5705,6 +5705,7 @@ test("national-pension normalizer requires a workplace name and derives the 6-di
|
|||
wkplNm: "테스트상사",
|
||||
bnoPrefix: "123456"
|
||||
});
|
||||
assert.throws(() => normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123" }), /10-digit/);
|
||||
});
|
||||
|
||||
test("parseNationalPensionXml classifies gateway auth errors and item lists", () => {
|
||||
|
|
@ -5750,10 +5751,13 @@ test("national-pension route orchestrates basic+detail+monthly and keeps the key
|
|||
headers: { "content-type": "application/xml" }
|
||||
});
|
||||
}
|
||||
return new Response(npsItemsXml([{ dataCrtYm: "202604" }, { dataCrtYm: "202605" }]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/xml" }
|
||||
});
|
||||
if (u.includes("getPdAcctoSttusInfoSearchV2")) {
|
||||
return new Response(npsItemsXml([{ dataCrtYm: "202604" }, { dataCrtYm: "202605" }]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/xml" }
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected NPS URL: ${u}`);
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
|
|
@ -5775,7 +5779,12 @@ test("national-pension route orchestrates basic+detail+monthly and keeps the key
|
|||
assert.equal(body.detail[0].jnngpCnt, "120");
|
||||
assert.equal(body.monthly_status[0].dataCrtYm, "202604");
|
||||
assert.equal(body.proxy.cache.hit, false);
|
||||
assert.match(calls[0], /serviceKey=data-go-key/);
|
||||
assert.deepEqual(calls.map((u) => new URL(u).pathname.split("/").pop()), [
|
||||
"getBassInfoSearchV2",
|
||||
"getDetailInfoSearchV2",
|
||||
"getPdAcctoSttusInfoSearchV2"
|
||||
]);
|
||||
assert.ok(calls.every((u) => new URL(u).searchParams.get("serviceKey") === "data-go-key"));
|
||||
assert.equal(JSON.stringify(body).includes("data-go-key"), false, "service key must not leak into the response");
|
||||
|
||||
const cached = await app.inject({
|
||||
|
|
@ -5810,11 +5819,14 @@ test("fsc corp normalizer requires a corporate name", () => {
|
|||
corpNm: "테스트",
|
||||
bno: "1234567890"
|
||||
});
|
||||
assert.throws(() => normalizeFscCorpQuery({ name: "테스트", b_no: "123" }), /10-digit/);
|
||||
});
|
||||
|
||||
test("fsc corp-outline route returns name-matched candidates and cross-checks bzno when present", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls += 1;
|
||||
assert.match(String(url), /corpNm=/);
|
||||
assert.match(String(url), /serviceKey=data-go-key/);
|
||||
return new Response(
|
||||
|
|
@ -5843,6 +5855,12 @@ test("fsc corp-outline route returns name-matched candidates and cross-checks bz
|
|||
assert.equal(body.candidate_count, 1);
|
||||
assert.equal(body.b_no_cross_check.checked, true);
|
||||
assert.equal(body.b_no_cross_check.matched_candidates.length, 1);
|
||||
const cached = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트") + "&b_no=1234567890"
|
||||
});
|
||||
assert.equal(cached.json().proxy.cache.hit, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
});
|
||||
|
||||
test("fsc corp-outline route maps upstream 403 to a 502 forbidden error", async (t) => {
|
||||
|
|
@ -5879,9 +5897,9 @@ test("g2b extractSanctionItems tolerates dict and single-item variants", () => {
|
|||
|
||||
test("g2b sanctioned-supplier route returns active sanctions and uses capital-S ServiceKey", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let seenUrl = "";
|
||||
const seenUrls = [];
|
||||
global.fetch = async (url) => {
|
||||
seenUrl = String(url);
|
||||
seenUrls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
|
|
@ -5902,8 +5920,12 @@ test("g2b sanctioned-supplier route returns active sanctions and uses capital-S
|
|||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(body.total_count, 1);
|
||||
assert.equal(body.active_sanctions[0].bizNm, "갑");
|
||||
assert.match(seenUrl, /ServiceKey=data-go-key/);
|
||||
assert.match(seenUrl, /inqryDiv=1/);
|
||||
assert.match(seenUrls[0], /ServiceKey=data-go-key/);
|
||||
assert.match(seenUrls[0], /inqryDiv=1/);
|
||||
|
||||
const cached = await app.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
|
||||
assert.equal(cached.json().proxy.cache.hit, true);
|
||||
assert.equal(seenUrls.length, 1);
|
||||
|
||||
const noKey = buildServer();
|
||||
t.after(async () => {
|
||||
|
|
@ -5911,4 +5933,35 @@ test("g2b sanctioned-supplier route returns active sanctions and uses capital-S
|
|||
});
|
||||
const missing = await noKey.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
|
||||
assert.equal(missing.statusCode, 503);
|
||||
|
||||
});
|
||||
|
||||
test("korean-law search endpoint returns 503 when the proxy server lacks LAW_OC", async (t) => {
|
||||
const app = buildServer();
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-law/search?target=law&query=%EA%B4%80%EC%84%B8%EB%B2%95"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("health endpoint reports koreanLawConfigured from LAW_OC", async (t) => {
|
||||
const off = buildServer();
|
||||
const on = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
await off.close();
|
||||
await on.close();
|
||||
});
|
||||
|
||||
const offBody = (await off.inject({ method: "GET", url: "/health" })).json();
|
||||
const onBody = (await on.inject({ method: "GET", url: "/health" })).json();
|
||||
|
||||
assert.equal(offBody.upstreams.koreanLawConfigured, false);
|
||||
assert.equal(onBody.upstreams.koreanLawConfigured, true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2008,7 +2008,7 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
|
|||
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
|
||||
});
|
||||
|
||||
test("repository docs advertise the korean-law-search skill with mode-specific korean-law-mcp setup guidance", () => {
|
||||
test("repository docs advertise the korean-law-search skill via k-skill-proxy", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
|
|
@ -2024,26 +2024,30 @@ test("repository docs advertise the korean-law-search skill with mode-specific k
|
|||
assert.match(readme, /\[한국 법령 검색 가이드\]\(docs\/features\/korean-law-search\.md\)/);
|
||||
assert.match(readme, /\| 한국 법령 검색 \| .* \| 불필요 \|/);
|
||||
assert.match(install, /--skill korean-law-search/);
|
||||
assert.match(install, /로컬 CLI\/MCP 경로는 `LAW_OC`/);
|
||||
assert.match(install, /remote endpoint는 `LAW_OC` 없이 `url`만/);
|
||||
assert.match(setup, /한국 법령 검색의 로컬 CLI\/MCP 경로용 `LAW_OC`/);
|
||||
assert.match(setup, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(featureDoc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(featureDoc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(setupSkill, /로컬 한국 법령 검색: `LAW_OC` \+ `korean-law-mcp`/);
|
||||
assert.match(setupSkill, /remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록/);
|
||||
assert.match(install, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(install, /운영자만 proxy 서버에 `LAW_OC`/);
|
||||
assert.match(setup, /한국 법령 검색은 기본 hosted proxy/);
|
||||
assert.match(setup, /self-host proxy 운영자만 서버 환경변수 `LAW_OC`/);
|
||||
assert.match(featureDoc, /\/v1\/korean-law\/search/);
|
||||
assert.match(featureDoc, /\/v1\/korean-law\/detail/);
|
||||
assert.match(setupSkill, /한국 법령 검색은 기본 hosted proxy/);
|
||||
assert.match(setupSkill, /운영자만 서버 환경변수 `LAW_OC`/);
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /LAW_OC/);
|
||||
assert.match(doc, /korean-law-mcp/);
|
||||
assert.match(doc, /k-skill-proxy/);
|
||||
}
|
||||
|
||||
assert.match(sources, /korean-law-mcp: https:\/\/github\.com\/chrisryugj\/korean-law-mcp/);
|
||||
assert.match(sources, /beopmang: https:\/\/api\.beopmang\.org/);
|
||||
assert.match(roadmap, /한국 법령 검색 스킬 출시/);
|
||||
|
||||
for (const doc of [readme, install, setup, security, setupSkill, sources, featureDoc]) {
|
||||
assert.doesNotMatch(doc, /법망|beopmang/i);
|
||||
assert.doesNotMatch(doc, /api\.beopmang\.org/);
|
||||
}
|
||||
});
|
||||
|
||||
test("korean-law-search skill keeps korean-law-mcp-first guidance while documenting the approved Beopmang fallback", () => {
|
||||
test("korean-law-search skill is proxy-first and drops the Beopmang fallback", () => {
|
||||
const skillPath = path.join(repoRoot, "korean-law-search", "SKILL.md");
|
||||
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
||||
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
||||
|
|
@ -2060,30 +2064,24 @@ test("korean-law-search skill keeps korean-law-mcp-first guidance while document
|
|||
const doneSection = doneSectionMatch[1];
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /korean-law-mcp.*먼저|먼저.*korean-law-mcp|항상 `korean-law-mcp`를 먼저 사용/u);
|
||||
assert.match(doc, /npm install -g korean-law-mcp/);
|
||||
assert.match(doc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(doc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(doc, /\/v1\/korean-law\/search/);
|
||||
assert.match(doc, /\/v1\/korean-law\/detail/);
|
||||
assert.match(doc, /target=law/);
|
||||
assert.match(doc, /target=prec/);
|
||||
assert.match(doc, /open\.law\.go\.kr/);
|
||||
assert.match(doc, /search_law/);
|
||||
assert.match(doc, /get_law_text/);
|
||||
assert.match(doc, /search_precedents/);
|
||||
assert.match(doc, /search_interpretations/);
|
||||
assert.match(doc, /search_ordinance/);
|
||||
assert.match(doc, /https:\/\/korean-law-mcp\.fly\.dev\/mcp/);
|
||||
assert.match(doc, /법망|Beopmang/i);
|
||||
assert.match(doc, /https:\/\/api\.beopmang\.org/);
|
||||
assert.match(doc, /fallback/i);
|
||||
assert.match(doc, /MCP/i);
|
||||
assert.match(doc, /CLI/i);
|
||||
assert.match(doc, /github\.com\/chrisryugj\/korean-law-mcp/);
|
||||
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(doc, /LAW_OC/);
|
||||
assert.doesNotMatch(doc, /법망|beopmang/i);
|
||||
assert.doesNotMatch(doc, /api\.beopmang\.org/);
|
||||
assert.doesNotMatch(doc, /npm install -g korean-law-mcp/);
|
||||
assert.doesNotMatch(doc, /packages\/korean-law-search/);
|
||||
assert.doesNotMatch(doc, /python-packages\/korean-law-search/);
|
||||
}
|
||||
|
||||
assert.match(doneSection, /search_interpretations/);
|
||||
assert.match(doneSection, /search_ordinance/);
|
||||
assert.match(doneSection, /법망|Beopmang/i);
|
||||
assert.match(doneSection, /fallback/i);
|
||||
assert.match(doneSection, /target=prec/);
|
||||
assert.match(doneSection, /target=ordin/);
|
||||
|
||||
assert.doesNotMatch(
|
||||
featureDoc,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue