Feature/#58 (#70)

* Add an official KIPRIS patent-search skill for Korean IP lookups

Issue #58 adds a bundled stdlib Python helper plus install/setup/docs coverage
for KIPRIS Plus keyword search and application-number detail lookup. The
implementation keeps auth explicit via KIPRIS_PLUS_API_KEY -> ServiceKey and
locks the repo contract with doc/install and regression tests.

Constraint: KIPRIS Plus requires a per-user ServiceKey and no valid key was available for live success-path runs
Constraint: No new dependencies allowed for bundled skill helpers
Rejected: Add a new npm/python workspace | docs+helper pattern already fits repo and keeps install payload lighter
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep the helper aligned with official KIPRIS Plus XML fields and ServiceKey naming before widening the skill surface
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: Successful live KIPRIS search with a valid production ServiceKey
Related: #58

* 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

* Record fresh verification for the approved KIPRIS key fix

The approved ServiceKey normalization fix is already present on feature/#58, so no further code edits were necessary. This empty follow-up commit records the requested rerun verification and keeps PR #70 moving without reopening a settled implementation path.

Constraint: Existing branch head already contains the approved code and docs fix
Rejected: Invent an extra code/doc change just to produce a non-empty diff | unnecessary risk after approval
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not change ServiceKey handling again without reproducing the percent-encoded portal-key path
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 request with a real KIPRIS_PLUS_API_KEY

* Record fresh Issue #58 verification without reopening the approved fix

The approved KIPRIS ServiceKey normalization change was already present on
feature/#58, so this follow-up records a fresh verification point for the
existing implementation instead of reopening the code path.

Constraint: User requested commit/push + PR follow-up on the existing issue branch
Rejected: Reopen the already-approved implementation | no new blocker or code defect remained
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If this helper changes again, preserve support for percent-encoded portal keys and keep the URL serialization regression coverage intact
Tested: patent helper smoke commands, patent/doc regression tests, lint, typecheck, full npm test suite, encoded-key reproduction, architect review
Not-tested: Live KIPRIS success path with a real KIPRIS_PLUS_API_KEY

* fix: decode percent-encoded ServiceKey in build_search_params and build_detail_params

The low-level helper functions still double-encoded percent-encoded
KIPRIS portal keys when callers bypassed resolve_service_key(). Apply
unquote() at the param-builder boundary so copied portal keys work
regardless of call path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: trigger GitHub merge-status recalculation

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-07 18:30:03 +09:00 committed by GitHub
commit b8ea339446
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1024 additions and 4 deletions

View file

@ -29,6 +29,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 한국 주식 정보 조회 | `k-skill-proxy` 경유로 KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 한국 특허 정보 검색 | KIPRIS Plus 공식 Open API로 한국 특허/실용신안 키워드 검색과 출원번호 상세 조회 | `KIPRIS_PLUS_API_KEY` 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
@ -81,6 +82,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)

View file

@ -0,0 +1,106 @@
# 한국 특허 정보 검색 가이드
## 이 기능으로 할 수 있는 일
- KIPRIS Plus 공식 Open API로 한국 특허/실용신안 키워드 검색
- 출원번호 기준 서지 상세 조회
- 출원번호, 발명의명칭, 출원인, IPC, 초록, 공개/공고/등록 메타데이터 정리
- JSON 형태로 후속 자동화에 넘기기
## 먼저 필요한 것
- 인터넷 연결
- `python3`
- KIPRIS Plus API key
- helper 환경변수: `KIPRIS_PLUS_API_KEY`
- 실제 요청 쿼리 파라미터: `ServiceKey`
- 설치된 `korean-patent-search` skill 안에 `scripts/patent_search.py` helper 포함
공공데이터포털 안내 기준으로 이 API는 개발계정은 자동승인, 운영계정은 심의승인 대상이다.
## 공식 표면
- KIPRIS Plus 포털: `https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15058788/openapi.do`
- helper 기본 endpoint: `https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getWordSearch`
- 상세 endpoint: `https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getBibliographyDetailInfoSearch`
## 기본 흐름
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값이어도 helper가 한 번 정규화한 뒤 요청한다.
2. 키워드 검색이면 `getWordSearch` 를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` 를 호출한다.
4. XML `response/header/body/items/item` 구조를 파싱한다.
5. JSON으로 출력한다.
## CLI 예시
### 키워드 검색
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
```
### 연도 + 페이지 지정
```bash
python3 scripts/patent_search.py --query "배터리" --year 2024 --page-no 1 --num-rows 5
```
### 출원번호 상세 조회
```bash
python3 scripts/patent_search.py --application-number 1020240001234
```
## 응답 예시 포맷
```json
{
"query": "배터리",
"page_no": 1,
"num_of_rows": 5,
"total_count": 24,
"items": [
{
"index_no": 1,
"application_number": "1020240001234",
"invention_title": "이차 전지 배터리 팩",
"register_status": "공개",
"application_date": "2024/01/02 00:00:00",
"open_number": "1020250005678",
"open_date": "2025/07/09 00:00:00",
"publication_number": "1020250005678",
"publication_date": "2025/07/09 00:00:00",
"ipc_number": "H01M 10/00",
"applicant_name": "주식회사 오픈에이아이코리아",
"abstract_text": "배터리 수명 향상을 위한 열 관리 구조."
}
]
}
```
## 구현 메모
- `getWordSearch` 요청 파라미터 핵심은 `word`, `year`, `patent`, `utility`, `ServiceKey` 이다.
- `getBibliographyDetailInfoSearch``applicationNumber`, `ServiceKey` 를 사용한다.
- helper는 표준 라이브러리 `urllib` + `xml.etree.ElementTree` 만 사용한다.
- 에러 응답의 `resultCode` / `resultMsg` 는 감추지 않고 그대로 surfaced 한다.
## Done when
- `python3 scripts/patent_search.py --query "배터리"` 형태의 검색 명령이 준비된다.
- `python3 scripts/patent_search.py --application-number 1020240001234` 형태의 상세 조회 명령이 준비된다.
- 키가 없거나 잘못됐을 때 `KIPRIS_PLUS_API_KEY` / `ServiceKey` 안내가 분명하다.
- 출력 JSON에 출원번호와 발명의명칭이 포함된다.
## 검증 메모
2026-04-05 기준 로컬에서 아래 항목을 실제 실행해 helper 동작을 검증했다.
- `python3 scripts/patent_search.py --help`
- dummy `ServiceKey` 로 KIPRIS Plus endpoint 호출 시 인증 오류가 명시적으로 surfaced 되는지 확인
- 단위 테스트로 `getWordSearch` / `getBibliographyDetailInfoSearch` XML 파싱 회귀 검증
유효한 `KIPRIS_PLUS_API_KEY` 가 준비된 환경에서는 바로 실검색으로 이어서 검증할 수 있다.

View file

@ -55,6 +55,7 @@ npx --yes skills add <owner/repo> \
--skill real-estate-search \
--skill korean-stock-search \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill fine-dust-location \
@ -82,6 +83,7 @@ npx --yes skills add <owner/repo> \
--skill real-estate-search \
--skill cheap-gas-nearby \
--skill joseon-sillok-search \
--skill korean-patent-search \
--skill seoul-subway-arrival \
--skill korea-weather \
--skill fine-dust-location
@ -190,6 +192,19 @@ npx --yes bunjang-cli --json chat send 84191651 --message "상품 상태 괜찮
```
`korean-patent-search` 는 설치된 skill payload 안의 helper를 그대로 쓴다.
- helper 환경변수는 `KIPRIS_PLUS_API_KEY`
- 실제 API 요청에서는 이 값을 `ServiceKey` 쿼리 파라미터로 보낸다
- 공공데이터포털에서 복사한 percent-encoded key를 그대로 넣어도 helper가 한 번 정규화해서 double-encoding 없이 보낸다
- KIPRIS Plus / 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 심의승인 대상이다
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리" --year 2024 --num-rows 5
python3 scripts/patent_search.py --application-number 1020240001234
```
로컬 저장소에서 바로 전체 설치 테스트:
```bash
@ -250,6 +265,13 @@ python3 -m pip install SRTrain korail2 pycryptodome
python3 scripts/sillok_search.py --query "훈민정음" --king 세종 --year 1443
```
한국 특허 정보 검색 helper는 설치된 `korean-patent-search` skill 안의 `scripts/patent_search.py` 를 그대로 쓰면 되고, 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
```
한국어 맞춤법 검사 helper는 별도 외부 패키지 없이 표준 라이브러리 `python3` 만 있으면 된다.
```bash
@ -277,6 +299,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`
- `korean-patent-search`
- `korean-stock-search`
- `cheap-gas-nearby`

View file

@ -19,6 +19,7 @@
- 한국 부동산 실거래가 조회 스킬 출시
- 한국 주식 정보 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 한국 특허 정보 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시

View file

@ -25,6 +25,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
@ -61,10 +62,11 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_KTX_ID`
- `KSKILL_KTX_PASSWORD`
- `LAW_OC`
- `KIPRIS_PLUS_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `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`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 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`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한국 날씨 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
`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`)를 경유하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. `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`, `KRX_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철/한국 날씨 route가 실제 배포된 proxy URL 로만 넣는다. 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회는 이 값이 없으면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`, self-host 프록시 운영용 서울 지하철/한국 날씨/미세먼지/한강홍수통제소 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다. 미세먼지, 한강 수위, 주유소 가격, 부동산 실거래가, 한국 주식 정보 조회는 기본 hosted proxy를 쓰므로 사용자 쪽 키가 불필요하다.
## Credential resolution order
@ -25,6 +25,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -45,6 +46,8 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
근처 가장 싼 주유소 찾기는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY` 는 helper가 읽는 표준 변수명이다. 실제 HTTP 요청에서는 같은 값을 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화해서 그대로 쓸 수 있다.
## 확인
```bash
@ -67,6 +70,7 @@ bash scripts/check-setup.sh
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
| 한국 주식 정보 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`) |
| 근처 가장 싼 주유소 찾기 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
@ -84,6 +88,7 @@ bash scripts/check-setup.sh
- [한강 수위 정보 가이드](features/han-river-water-level.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [한국 특허 정보 검색 가이드](features/korean-patent-search.md)
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [보안/시크릿 정책](security-and-secrets.md)

View file

@ -71,6 +71,10 @@
- 조선왕조실록 메인: https://sillok.history.go.kr
- 조선왕조실록 검색 결과: https://sillok.history.go.kr/search/searchResultList.do
- 조선왕조실록 기사 상세: https://sillok.history.go.kr/id/kda_12512030_002
- KIPRIS Plus 특허/실용신안 API 목록: https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100
- 공공데이터포털 특허/실용신안 정보 검색 서비스: https://www.data.go.kr/data/15058788/openapi.do
- KIPRIS Plus 특허/실용신안 검색 endpoint: https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getWordSearch
- KIPRIS Plus 특허/실용신안 서지상세 endpoint: https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice/getBibliographyDetailInfoSearch
- Opinet 오픈 API 안내: https://www.opinet.co.kr/user/custapi/openApiInfo.do
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do

View file

@ -3,5 +3,6 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com

View file

@ -68,6 +68,7 @@ KSKILL_SRT_PASSWORD=replace-me
KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -87,6 +88,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다.
한국 특허 정보 검색은 KIPRIS Plus Open API 경로를 쓸 때 `KIPRIS_PLUS_API_KEY` 를 채운다. helper는 이 값을 읽어 실제 요청에서 `ServiceKey` 쿼리 파라미터로 보낸다. 공공데이터포털에서 복사한 percent-encoded key도 그대로 넣어도 된다.
### Missing secret response template
인증 스킬에서 값이 빠졌을 때는 credential resolution order에 따라 확보한다.
@ -98,6 +101,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
- 근처 가장 싼 주유소 찾기: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`

View file

@ -0,0 +1,89 @@
---
name: korean-patent-search
description: Search Korean patent and utility-model publications through the official KIPRIS Plus Open API with keyword search plus application-number detail lookup.
license: MIT
metadata:
category: ip
locale: ko-KR
phase: v1
---
# 한국 특허 정보 검색
## What this skill does
KIPRIS Plus(키프리스 플러스) 공식 Open API로 한국 특허/실용신안 공개·공고 데이터를 검색한다.
v1 범위:
- 키워드 검색 (`getWordSearch`)
- 출원번호 기준 서지 상세 조회 (`getBibliographyDetailInfoSearch`)
- 구조화된 JSON 출력
- 표준 `python3` helper 동봉
## When to use
- "배터리 관련 한국 특허 찾아줘"
- "출원번호 1020240001234 특허 요약 보여줘"
- "KIPRIS API로 특허 검색 결과를 JSON으로 받고 싶어"
- "출원인/IPC/초록까지 포함한 한국 특허 검색 결과가 필요해"
## Prerequisites
- 인터넷 연결
- `python3`
- KIPRIS Plus에서 발급받은 API 키
- helper 환경변수: `KIPRIS_PLUS_API_KEY`
- 실제 요청 쿼리 파라미터명: `ServiceKey`
- 설치된 skill payload 안에 `scripts/patent_search.py` helper 포함
## Inputs
- 키워드 검색
- 필수: `--query`
- 선택: `--year`
- 선택: `--page-no`
- 선택: `--num-rows`
- 선택: `--exclude-patent`
- 선택: `--exclude-utility`
- 상세 조회
- 필수: `--application-number`
## Workflow
1. `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 로 ServiceKey를 확보한다. 공공데이터포털에서 복사한 percent-encoded 값도 helper가 한 번 정규화해서 그대로 받을 수 있다.
2. 키워드 검색이면 `getWordSearch` endpoint를 호출한다.
3. 출원번호 상세 조회면 `getBibliographyDetailInfoSearch` endpoint를 호출한다.
4. XML 응답의 header/body/items 구조를 파싱한다.
5. 출원번호, 발명의명칭, 출원인, 초록, 공개/공고/등록 메타데이터를 JSON으로 정리한다.
## CLI examples
```bash
export KIPRIS_PLUS_API_KEY=your-service-key
python3 scripts/patent_search.py --query "배터리"
python3 scripts/patent_search.py --query "배터리" --year 2024 --num-rows 5
python3 scripts/patent_search.py --application-number 1020240001234
```
## Response policy
- 공식 KIPRIS Plus Open API 응답만 사용한다.
- 키가 없으면 `KIPRIS_PLUS_API_KEY` 또는 `--service-key` 를 정확히 안내한다.
- 검색 결과는 최소한 출원번호, 발명의명칭, 출원일자, 출원인, 초록을 포함해 정리한다.
- 상세 조회는 `getBibliographyDetailInfoSearch` 기준으로 공개/공고/등록 메타데이터를 함께 정리한다.
- API 에러 코드는 숨기지 말고 그대로 surfaced 한다.
## Done when
- 유효한 ServiceKey로 `getWordSearch` 또는 `getBibliographyDetailInfoSearch` 호출이 가능하다.
- helper가 JSON을 출력한다.
- 에러 시 `KIPRIS_PLUS_API_KEY` / `ServiceKey` 관련 안내가 분명하다.
- 응답에 출원번호와 발명의명칭이 포함된다.
## Notes
- KIPRIS Plus 포털: `https://plus.kipris.or.kr/portal/data/service/List.do?subTab=SC001&entYn=N&menuNo=200100`
- 공공데이터포털 문서: `https://www.data.go.kr/data/15058788/openapi.do`
- v1 helper는 `getWordSearch`, `getBibliographyDetailInfoSearch` 두 operation에 집중한다.
- 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 별도 심의 대상이다.

View file

@ -0,0 +1,338 @@
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass
from typing import Callable
SERVICE_KEY_ENV_VAR = "KIPRIS_PLUS_API_KEY"
DEFAULT_TIMEOUT = 30
DEFAULT_NUM_ROWS = 10
DEFAULT_PAGE_NO = 1
BASE_API_URL = "https://plus.kipris.or.kr/kipo-api/kipi/patUtiModInfoSearchSevice"
SEARCH_OPERATION = "getWordSearch"
DETAIL_OPERATION = "getBibliographyDetailInfoSearch"
DEFAULT_HEADERS = {
"Accept": "application/xml,text/xml;q=0.9,*/*;q=0.8",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"
),
}
@dataclass(frozen=True)
class PatentSearchResult:
index_no: int | None
application_number: str
invention_title: str | None
register_status: str | None
application_date: str | None
open_number: str | None
open_date: str | None
publication_number: str | None
publication_date: str | None
register_number: str | None
register_date: str | None
ipc_number: str | None
abstract_text: str | None
applicant_name: str | None
drawing: str | None
big_drawing: str | None
@dataclass(frozen=True)
class PatentSearchResponse:
query: str
page_no: int
num_of_rows: int
total_count: int
items: list[PatentSearchResult]
@dataclass(frozen=True)
class PatentDetail:
application_number: str
invention_title: str | None
register_status: str | None
application_date: str | None
open_number: str | None
open_date: str | None
publication_number: str | None
publication_date: str | None
register_number: str | None
register_date: str | None
ipc_number: str | None
abstract_text: str | None
applicant_name: str | None
drawing: str | None
big_drawing: str | None
def clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = " ".join(value.split()).strip()
return cleaned or None
def parse_positive_int(raw_value: str) -> int:
value = int(raw_value)
if value <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return value
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 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)."
)
def build_operation_url(operation: str) -> str:
return f"{BASE_API_URL}/{operation}"
def build_search_params(
*,
query: str,
year: int | None = None,
page_no: int = DEFAULT_PAGE_NO,
num_of_rows: int = DEFAULT_NUM_ROWS,
patent: bool = True,
utility: bool = True,
service_key: str,
) -> dict[str, str]:
if not patent and not utility:
raise ValueError("At least one of patent or utility must remain enabled for keyword search.")
params = {
"word": query,
"patent": "true" if patent else "false",
"utility": "true" if utility else "false",
"pageNo": str(page_no),
"numOfRows": str(num_of_rows),
"ServiceKey": urllib.parse.unquote(service_key),
}
if year is not None:
params["year"] = str(year)
return params
def build_detail_params(*, application_number: str, service_key: str) -> dict[str, str]:
return {"applicationNumber": application_number, "ServiceKey": urllib.parse.unquote(service_key)}
def fetch_xml(url: str, params: dict[str, str], timeout: int = DEFAULT_TIMEOUT) -> str:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(request_url, headers=DEFAULT_HEADERS)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"KIPRIS Plus HTTP {exc.code}: {body or exc.reason}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Failed to reach KIPRIS Plus API: {exc.reason}") from exc
def get_child_text(element: ET.Element | None, tag_name: str) -> str | None:
if element is None:
return None
child = element.find(tag_name)
return clean_text(child.text if child is not None else None)
def parse_int(value: str | None) -> int | None:
if value is None:
return None
return int(value)
def parse_xml_response(xml_text: str) -> ET.Element:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise RuntimeError(f"Failed to parse KIPRIS Plus XML response: {exc}") from exc
result_code = get_child_text(root.find("header"), "resultCode")
result_msg = get_child_text(root.find("header"), "resultMsg")
if result_code and result_code != "00":
raise RuntimeError(result_msg or f"KIPRIS Plus API error code {result_code}")
return root
def parse_patent_item(item: ET.Element) -> PatentSearchResult:
application_number = get_child_text(item, "applicationNumber")
if not application_number:
raise RuntimeError("KIPRIS Plus response item is missing applicationNumber")
return PatentSearchResult(
index_no=parse_int(get_child_text(item, "indexNo")),
application_number=application_number,
invention_title=get_child_text(item, "inventionTitle"),
register_status=get_child_text(item, "registerStatus"),
application_date=get_child_text(item, "applicationDate"),
open_number=get_child_text(item, "openNumber"),
open_date=get_child_text(item, "openDate"),
publication_number=get_child_text(item, "publicationNumber"),
publication_date=get_child_text(item, "publicationDate"),
register_number=get_child_text(item, "registerNumber"),
register_date=get_child_text(item, "registerDate"),
ipc_number=get_child_text(item, "ipcNumber"),
abstract_text=get_child_text(item, "astrtCont"),
applicant_name=get_child_text(item, "applicantName"),
drawing=get_child_text(item, "drawing"),
big_drawing=get_child_text(item, "bigDrawing"),
)
def parse_patent_search_response(xml_text: str, *, query: str) -> PatentSearchResponse:
root = parse_xml_response(xml_text)
body = root.find("body")
items_parent = body.find("items") if body is not None else None
item_elements = items_parent.findall("item") if items_parent is not None else []
items = [parse_patent_item(item) for item in item_elements]
return PatentSearchResponse(
query=query,
page_no=parse_int(get_child_text(body, "pageNo")) or DEFAULT_PAGE_NO,
num_of_rows=parse_int(get_child_text(body, "numOfRows")) or len(items),
total_count=parse_int(get_child_text(body, "totalCount")) or len(items),
items=items,
)
def parse_patent_detail_response(xml_text: str) -> PatentDetail:
root = parse_xml_response(xml_text)
body = root.find("body")
item = body.find("item") if body is not None else None
if item is None and body is not None:
items_parent = body.find("items")
item = items_parent.find("item") if items_parent is not None else None
if item is None:
raise RuntimeError("KIPRIS Plus detail response did not include an item payload")
search_item = parse_patent_item(item)
return PatentDetail(
application_number=search_item.application_number,
invention_title=search_item.invention_title,
register_status=search_item.register_status,
application_date=search_item.application_date,
open_number=search_item.open_number,
open_date=search_item.open_date,
publication_number=search_item.publication_number,
publication_date=search_item.publication_date,
register_number=search_item.register_number,
register_date=search_item.register_date,
ipc_number=search_item.ipc_number,
abstract_text=search_item.abstract_text,
applicant_name=search_item.applicant_name,
drawing=search_item.drawing,
big_drawing=search_item.big_drawing,
)
def search_patents(
query: str,
*,
year: int | None = None,
page_no: int = DEFAULT_PAGE_NO,
num_of_rows: int = DEFAULT_NUM_ROWS,
patent: bool = True,
utility: bool = True,
service_key: str | None = None,
fetcher: Callable[[str, dict[str, str], int], str] = fetch_xml,
timeout: int = DEFAULT_TIMEOUT,
) -> PatentSearchResponse:
key = resolve_service_key(service_key)
xml_text = fetcher(
build_operation_url(SEARCH_OPERATION),
build_search_params(
query=query,
year=year,
page_no=page_no,
num_of_rows=num_of_rows,
patent=patent,
utility=utility,
service_key=key,
),
timeout,
)
return parse_patent_search_response(xml_text, query=query)
def get_patent_detail(
application_number: str,
*,
service_key: str | None = None,
fetcher: Callable[[str, dict[str, str], int], str] = fetch_xml,
timeout: int = DEFAULT_TIMEOUT,
) -> PatentDetail:
key = resolve_service_key(service_key)
xml_text = fetcher(
build_operation_url(DETAIL_OPERATION),
build_detail_params(application_number=application_number, service_key=key),
timeout,
)
return parse_patent_detail_response(xml_text)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Search Korean patent information via the official KIPRIS Plus Open API."
)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--query", help="Keyword for KIPRIS getWordSearch")
mode.add_argument("--application-number", help="Application number for bibliography detail lookup")
parser.add_argument("--year", type=parse_positive_int, help="Optional year filter for keyword search")
parser.add_argument("--page-no", type=parse_positive_int, default=DEFAULT_PAGE_NO, help="Response page number")
parser.add_argument("--num-rows", type=parse_positive_int, default=DEFAULT_NUM_ROWS, help="Rows per page")
parser.add_argument("--service-key", help=f"KIPRIS Plus ServiceKey (defaults to ${SERVICE_KEY_ENV_VAR})")
parser.add_argument("--exclude-patent", action="store_true", help="Exclude patent results from keyword search")
parser.add_argument("--exclude-utility", action="store_true", help="Exclude utility-model results from keyword search")
parser.add_argument("--timeout", type=parse_positive_int, default=DEFAULT_TIMEOUT, help="HTTP timeout seconds")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
if args.query:
payload = search_patents(
args.query,
year=args.year,
page_no=args.page_no,
num_of_rows=args.num_rows,
patent=not args.exclude_patent,
utility=not args.exclude_utility,
service_key=args.service_key,
timeout=args.timeout,
)
else:
payload = get_patent_detail(
args.application_number,
service_key=args.service_key,
timeout=args.timeout,
)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
print(json.dumps(asdict(payload), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())

View file

@ -9,9 +9,9 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.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 && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "node --test scripts/skill-docs.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

13
scripts/patent_search.py Normal file
View file

@ -0,0 +1,13 @@
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = (
Path(__file__).resolve().parent.parent / "korean-patent-search" / "scripts" / "patent_search.py"
)
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled patent helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -1446,6 +1446,79 @@ test("joseon-sillok-search install payload includes the documented helper comman
}
});
test("repository docs advertise the korean-patent-search skill and official KIPRIS Plus API setup", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
const featureDocPath = path.join(repoRoot, "docs", "features", "korean-patent-search.md");
const featureDoc = read(path.join("docs", "features", "korean-patent-search.md"));
const skillPath = path.join(repoRoot, "korean-patent-search", "SKILL.md");
const skill = read(path.join("korean-patent-search", "SKILL.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const packageJson = readJson("package.json");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/korean-patent-search.md to exist");
assert.ok(fs.existsSync(skillPath), "expected korean-patent-search/SKILL.md to exist");
assert.match(readme, /\| 한국 특허 정보 검색 \|/);
assert.match(readme, /\[한국 특허 정보 검색 가이드\]\(docs\/features\/korean-patent-search\.md\)/);
assert.match(install, /--skill korean-patent-search/);
assert.match(install, /KIPRIS_PLUS_API_KEY/);
assert.match(install, /python3 scripts\/patent_search\.py --query "배터리"/);
assert.match(setup, /한국 특허 정보 검색의 KIPRIS Plus 경로용 `KIPRIS_PLUS_API_KEY`/);
assert.match(security, /KIPRIS_PLUS_API_KEY/);
assert.match(setupSkill, /한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`/);
assert.match(examplesSecrets, /^KIPRIS_PLUS_API_KEY=replace-me$/m);
assert.match(skill, /^name: korean-patent-search$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /KIPRIS Plus/i);
assert.match(doc, /getWordSearch/);
assert.match(doc, /getBibliographyDetailInfoSearch/);
assert.match(doc, /ServiceKey/);
assert.match(doc, /python3 scripts\/patent_search\.py/);
assert.match(doc, /Done when/i);
assert.doesNotMatch(doc, /packages\/korean-patent-search/);
assert.doesNotMatch(doc, /python-packages\/korean-patent-search/);
}
assert.match(sources, /https:\/\/plus\.kipris\.or\.kr\/portal\/data\/service\/List\.do\?subTab=SC001&entYn=N&menuNo=200100/);
assert.match(sources, /https:\/\/www\.data\.go\.kr\/data\/15058788\/openapi\.do/);
assert.match(roadmap, /한국 특허 정보 검색 스킬 출시/);
assert.ok(
!packageJson.workspaces.some((workspace) => workspace.includes("korean-patent-search")),
"expected no repo workspace to be added for korean-patent-search",
);
assert.equal(fs.existsSync(path.join(repoRoot, "packages", "korean-patent-search")), false);
});
test("korean-patent-search install payload includes the documented helper command", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "korean-patent-search-"));
const installedSkillPath = path.join(tempRoot, "korean-patent-search");
const bundledHelperPath = path.join(installedSkillPath, "scripts", "patent_search.py");
try {
fs.cpSync(path.join(repoRoot, "korean-patent-search"), installedSkillPath, { recursive: true });
assert.ok(fs.existsSync(bundledHelperPath), "expected korean-patent-search/scripts/patent_search.py to exist");
const helpText = childProcess.execFileSync("python3", ["scripts/patent_search.py", "--help"], {
cwd: installedSkillPath,
encoding: "utf8",
});
assert.match(helpText, /Search Korean patent information via the official KIPRIS Plus Open API/);
assert.match(helpText, /--query/);
assert.match(helpText, /--application-number/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("repository docs advertise the real-estate-search skill and proxy-based approach", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));

View file

@ -0,0 +1,359 @@
import contextlib
import io
import unittest
from unittest import mock
from scripts.patent_search import (
PatentDetail,
PatentSearchResponse,
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,
)
SAMPLE_SEARCH_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>00</resultCode>
<resultMsg>NORMAL SERVICE</resultMsg>
</header>
<body>
<items>
<item>
<indexNo>1</indexNo>
<registerStatus>공개</registerStatus>
<inventionTitle>이차 전지 배터리 </inventionTitle>
<ipcNumber>H01M 10/00</ipcNumber>
<registerNumber>1023456789000</registerNumber>
<registerDate>2024/01/15 00:00:00</registerDate>
<applicationNumber>1020240001234</applicationNumber>
<applicationDate>2024/01/02 00:00:00</applicationDate>
<openNumber>1020250005678</openNumber>
<openDate>2025/07/09 00:00:00</openDate>
<publicationNumber>1020250005678</publicationNumber>
<publicationDate>2025/07/09 00:00:00</publicationDate>
<astrtCont>배터리 수명 향상을 위한 관리 구조.</astrtCont>
<bigDrawing>http://example.com/big.png</bigDrawing>
<drawing>http://example.com/thumb.png</drawing>
<applicantName>주식회사 오픈에이아이코리아</applicantName>
</item>
<item>
<indexNo>2</indexNo>
<registerStatus>등록</registerStatus>
<inventionTitle>배터리 모듈 고정장치</inventionTitle>
<ipcNumber>H01M 50/20</ipcNumber>
<applicationNumber>1020240009999</applicationNumber>
<applicationDate>2024/02/18 00:00:00</applicationDate>
<astrtCont>모듈 조립성을 높이는 고정장치.</astrtCont>
<applicantName>주식회사 샘플</applicantName>
</item>
</items>
<numOfRows>2</numOfRows>
<pageNo>1</pageNo>
<totalCount>24</totalCount>
</body>
</response>
"""
SAMPLE_DETAIL_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>00</resultCode>
<resultMsg>NORMAL SERVICE</resultMsg>
</header>
<body>
<item>
<applicationNumber>1020240001234</applicationNumber>
<inventionTitle>이차 전지 배터리 </inventionTitle>
<registerStatus>공개</registerStatus>
<applicationDate>2024/01/02 00:00:00</applicationDate>
<openNumber>1020250005678</openNumber>
<openDate>2025/07/09 00:00:00</openDate>
<publicationNumber>1020250005678</publicationNumber>
<publicationDate>2025/07/09 00:00:00</publicationDate>
<registerNumber>1023456789000</registerNumber>
<registerDate>2024/01/15 00:00:00</registerDate>
<ipcNumber>H01M 10/00</ipcNumber>
<applicantName>주식회사 오픈에이아이코리아</applicantName>
<astrtCont>배터리 수명 향상을 위한 관리 구조.</astrtCont>
<drawing>http://example.com/thumb.png</drawing>
<bigDrawing>http://example.com/big.png</bigDrawing>
</item>
</body>
</response>
"""
SAMPLE_AUTH_ERROR_XML = """<?xml version="1.0" encoding="UTF-8"?>
<response>
<header>
<resultCode>10</resultCode>
<resultMsg>API KEY를 잘못 입력하셨습니다.(SERVICE KEY IS NOT REGISTERED ERROR.[30])</resultMsg>
</header>
</response>
"""
class ParsePatentSearchResponseTest(unittest.TestCase):
def test_parses_items_and_paging_metadata(self):
report = parse_patent_search_response(SAMPLE_SEARCH_XML, query="배터리")
self.assertIsInstance(report, PatentSearchResponse)
self.assertEqual(report.query, "배터리")
self.assertEqual(report.total_count, 24)
self.assertEqual(report.page_no, 1)
self.assertEqual(report.num_of_rows, 2)
self.assertEqual(len(report.items), 2)
self.assertIsInstance(report.items[0], PatentSearchResult)
self.assertEqual(report.items[0].application_number, "1020240001234")
self.assertEqual(report.items[0].invention_title, "이차 전지 배터리 팩")
self.assertEqual(report.items[0].abstract_text, "배터리 수명 향상을 위한 열 관리 구조.")
self.assertEqual(report.items[0].applicant_name, "주식회사 오픈에이아이코리아")
class ParsePatentDetailResponseTest(unittest.TestCase):
def test_parses_detail_item(self):
detail = parse_patent_detail_response(SAMPLE_DETAIL_XML)
self.assertIsInstance(detail, PatentDetail)
self.assertEqual(detail.application_number, "1020240001234")
self.assertEqual(detail.invention_title, "이차 전지 배터리 팩")
self.assertEqual(detail.register_status, "공개")
self.assertEqual(detail.big_drawing, "http://example.com/big.png")
class RequestBuilderTest(unittest.TestCase):
def test_build_search_params_include_service_key_and_paging(self):
params = build_search_params(
query="배터리",
year=2024,
page_no=2,
num_of_rows=5,
patent=True,
utility=False,
service_key="test-key",
)
self.assertEqual(params["word"], "배터리")
self.assertEqual(params["year"], "2024")
self.assertEqual(params["patent"], "true")
self.assertEqual(params["utility"], "false")
self.assertEqual(params["pageNo"], "2")
self.assertEqual(params["numOfRows"], "5")
self.assertEqual(params["ServiceKey"], "test-key")
def test_build_detail_params_only_requires_application_number_and_service_key(self):
params = build_detail_params(application_number="1020240001234", service_key="test-key")
self.assertEqual(params, {"applicationNumber": "1020240001234", "ServiceKey": "test-key"})
def test_build_search_params_requires_at_least_one_document_type(self):
with self.assertRaisesRegex(ValueError, "At least one of patent or utility"):
build_search_params(
query="배터리",
patent=False,
utility=False,
service_key="test-key",
)
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"])
def test_build_search_params_decodes_percent_encoded_service_key(self):
"""Callers passing a raw percent-encoded key directly into build_search_params
must not trigger double-encoding when urlencode serializes the dict."""
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
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_search_params(query="배터리", service_key="abc%2Bdef%3D%3D"),
)
self.assertIn("ServiceKey=abc%2Bdef%3D%3D", captured["url"])
self.assertNotIn("%252B", captured["url"])
self.assertNotIn("%253D", captured["url"])
def test_build_detail_params_decodes_percent_encoded_service_key(self):
"""Same guard for build_detail_params direct callers."""
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
return FakeResponse()
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
fetch_xml(
"https://example.test/patent",
build_detail_params(application_number="1020240001234", service_key="abc%2Bdef%3D%3D"),
)
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 = []
def fake_fetcher(url, params, timeout):
calls.append((url, params, timeout))
return SAMPLE_SEARCH_XML
report = search_patents("배터리", service_key="test-key", fetcher=fake_fetcher, page_no=3, num_of_rows=7)
self.assertEqual(report.page_no, 1)
self.assertEqual(report.items[0].application_number, "1020240001234")
self.assertTrue(calls[0][0].endswith("/getWordSearch"))
self.assertEqual(calls[0][1]["ServiceKey"], "test-key")
self.assertEqual(calls[0][1]["pageNo"], "3")
self.assertEqual(calls[0][1]["numOfRows"], "7")
def test_get_patent_detail_uses_detail_endpoint(self):
calls = []
def fake_fetcher(url, params, timeout):
calls.append((url, params, timeout))
return SAMPLE_DETAIL_XML
detail = get_patent_detail("1020240001234", service_key="test-key", fetcher=fake_fetcher)
self.assertEqual(detail.application_number, "1020240001234")
self.assertTrue(calls[0][0].endswith("/getBibliographyDetailInfoSearch"))
self.assertEqual(calls[0][1]["applicationNumber"], "1020240001234")
def test_search_patents_surfaces_api_auth_errors_cleanly(self):
with self.assertRaisesRegex(RuntimeError, "SERVICE KEY IS NOT REGISTERED ERROR"):
search_patents(
"배터리",
service_key="bad-key",
fetcher=lambda url, params, timeout: SAMPLE_AUTH_ERROR_XML,
)
class CliTest(unittest.TestCase):
def test_parse_args_supports_query_and_application_number_modes(self):
args = parse_args(["--query", "배터리", "--year", "2024", "--num-rows", "5"])
self.assertEqual(args.query, "배터리")
self.assertEqual(args.year, 2024)
self.assertEqual(args.num_rows, 5)
detail_args = parse_args(["--application-number", "1020240001234"])
self.assertEqual(detail_args.application_number, "1020240001234")
def test_main_prints_query_report_as_json(self):
with mock.patch("scripts.patent_search.search_patents") as search_mock:
search_mock.return_value = PatentSearchResponse(
query="배터리",
page_no=1,
num_of_rows=1,
total_count=1,
items=[
PatentSearchResult(
index_no=1,
application_number="1020240001234",
invention_title="이차 전지 배터리 팩",
register_status="공개",
application_date="2024/01/02 00:00:00",
open_number="1020250005678",
open_date="2025/07/09 00:00:00",
publication_number="1020250005678",
publication_date="2025/07/09 00:00:00",
register_number=None,
register_date=None,
ipc_number="H01M 10/00",
abstract_text="배터리 수명 향상을 위한 열 관리 구조.",
applicant_name="주식회사 오픈에이아이코리아",
drawing="http://example.com/thumb.png",
big_drawing="http://example.com/big.png",
)
],
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = main(["--query", "배터리", "--service-key", "test-key"])
self.assertEqual(exit_code, 0)
self.assertIn('"query": "배터리"', stdout.getvalue())
def test_main_reports_missing_api_key(self):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
exit_code = main(["--query", "배터리"])
self.assertEqual(exit_code, 2)
self.assertIn("KIPRIS_PLUS_API_KEY", stderr.getvalue())