mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
d5034daaaf
commit
b8ea339446
15 changed files with 1024 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
106
docs/features/korean-patent-search.md
Normal file
106
docs/features/korean-patent-search.md
Normal 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` 가 준비된 환경에서는 바로 실검색으로 이어서 검증할 수 있다.
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
- 한국 부동산 실거래가 조회 스킬 출시
|
||||
- 한국 주식 정보 조회 스킬 출시
|
||||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
|
|
|
|||
|
|
@ -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)를 본다.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
89
korean-patent-search/SKILL.md
Normal file
89
korean-patent-search/SKILL.md
Normal 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에 집중한다.
|
||||
- 공공데이터포털 안내 기준으로 개발계정은 자동승인, 운영계정은 별도 심의 대상이다.
|
||||
338
korean-patent-search/scripts/patent_search.py
Normal file
338
korean-patent-search/scripts/patent_search.py
Normal 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())
|
||||
|
|
@ -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
13
scripts/patent_search.py
Normal 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())
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
359
scripts/test_patent_search.py
Normal file
359
scripts/test_patent_search.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue