Accept copied KIPRIS portal keys without request corruption

KIPRIS Plus users commonly paste the percent-encoded ServiceKey shown by the
portal. The helper now normalizes that key once before query serialization,
adds regression coverage for explicit and env-driven inputs, and clarifies the
documentation so copied portal keys remain valid instead of being double-
encoded on the wire.

Constraint: KIPRIS Plus still expects the standard ServiceKey query parameter contract
Rejected: Preserve raw percent signs during urlencode only for ServiceKey | more brittle than normalizing once at the boundary
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep ServiceKey normalization at the input boundary so future request-building changes do not reintroduce double-encoding
Tested: python3 scripts/patent_search.py --help; python3 scripts/patent_search.py --query '배터리' --service-key dummy; python3 scripts/patent_search.py --application-number 1020240001234 --service-key dummy; PYTHONPATH=.:scripts python3 -m unittest scripts.test_patent_search; node --test scripts/skill-docs.test.js; npm run lint; npm run typecheck; npm test
Not-tested: Live KIPRIS Plus lookup with a real KIPRIS_PLUS_API_KEY
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-05 22:04:42 +09:00
commit 3c5bc5b4e9
8 changed files with 52 additions and 6 deletions

View file

@ -27,7 +27,7 @@
## 기본 흐름
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다.
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값이어도 helper가 한 번 정규화한 뒤 요청한다.
2. 키워드 검색이면 `getWordSearch` 를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` 를 호출한다.
4. XML `response/header/body/items/item` 구조를 파싱한다.

View file

@ -116,6 +116,7 @@ shared HTTP가 필요하면 upstream Docker guide 대로 서버를 한 번 띄
- helper 환경변수는 `KIPRIS_PLUS_API_KEY`
- 실제 API 요청에서는 이 값을 `ServiceKey` 쿼리 파라미터로 보낸다
- 공공데이터포털에서 복사한 percent-encoded key를 그대로 넣어도 helper가 한 번 정규화해서 double-encoding 없이 보낸다
- KIPRIS Plus / 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 심의승인 대상이다
```bash

View file

@ -68,6 +68,6 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `AIR_KOREA_OPEN_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` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `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` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -43,7 +43,7 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
한국 부동산 실거래가 조회의 로컬/self-host 경로는 upstream `real-estate-mcp` 가 읽는 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 upstream 문서에는 고정 public endpoint가 없어 self-host를 기본으로 보고, Cloudflare Tunnel/operator secret은 운영자별 값이라 기본 client secrets 파일에는 넣지 않는다.
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다.
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
## 확인

View file

@ -84,7 +84,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
한국 부동산 실거래가 조회는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로를 쓸 때 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 고정 public endpoint는 확인하지 못했으므로 shared URL이 필요하면 self-host + Cloudflare Tunnel + launchd(systemd) 운영을 먼저 설명한다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
### Missing secret response template

View file

@ -51,7 +51,7 @@ v1 범위:
## Workflow
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다.
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값도 helper가 한 번 정규화해서 그대로 받을 수 있다.
2. 키워드 검색이면 `getWordSearch` endpoint를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` endpoint를 호출한다.
4. XML 응답의 header/body/items 구조를 파싱한다.

View file

@ -92,7 +92,7 @@ def parse_positive_int(raw_value: str) -> int:
def resolve_service_key(explicit_key: str | None = None) -> str:
candidate = clean_text(explicit_key) or clean_text(os.getenv(SERVICE_KEY_ENV_VAR))
if candidate:
return candidate
return urllib.parse.unquote(candidate)
raise ValueError(
f"missing {SERVICE_KEY_ENV_VAR}. Export {SERVICE_KEY_ENV_VAR} or pass --service-key "
"(mapped to the KIPRIS Plus ServiceKey query parameter)."

View file

@ -9,11 +9,13 @@ from scripts.patent_search import (
PatentSearchResult,
build_detail_params,
build_search_params,
fetch_xml,
get_patent_detail,
main,
parse_args,
parse_patent_detail_response,
parse_patent_search_response,
resolve_service_key,
search_patents,
)
@ -163,6 +165,49 @@ class RequestBuilderTest(unittest.TestCase):
)
class ServiceKeyEncodingTest(unittest.TestCase):
def test_resolve_service_key_accepts_percent_encoded_portal_value(self):
self.assertEqual(resolve_service_key("abc%2Bdef%3D%3D"), "abc+def==")
def test_resolve_service_key_decodes_percent_encoded_env_value(self):
with mock.patch.dict(
"scripts.patent_search.os.environ",
{"KIPRIS_PLUS_API_KEY": "abc%2Bdef%3D%3D"},
clear=True,
):
self.assertEqual(resolve_service_key(), "abc+def==")
def test_fetch_xml_does_not_double_encode_percent_encoded_service_key(self):
captured = {}
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b"<response><header><resultCode>00</resultCode></header></response>"
def fake_urlopen(request, timeout):
captured["url"] = request.full_url
captured["timeout"] = timeout
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_search_params(query="배터리", service_key=resolve_service_key("abc%2Bdef%3D%3D")),
timeout=7,
)
self.assertEqual(captured["timeout"], 7)
self.assertIn("ServiceKey=abc%2Bdef%3D%3D", captured["url"])
self.assertNotIn("%252B", captured["url"])
self.assertNotIn("%253D", captured["url"])
class PatentSearchWorkflowTest(unittest.TestCase):
def test_search_patents_uses_fetcher_and_returns_parsed_report(self):
calls = []