instruction.md
# KERIS Academic Search
## What this skill does
KERIS가 운영하는 RISS 검색 Open API에서 학위논문, 국내외 학술논문, 단행본, 연구보고서, 학술지 메타데이터를 조회한다. 제목, 저자, 발행처, 연도, RISS 상세 링크, API가 반환한 원문 유무/무료 표시를 요약한다.
조회 전용이다. 기관 로그인, 구독 권한 우회, 복사·대출 신청, 유료 결제, 원문 다운로드는 자동화하지 않는다.
## Official access path
1. 기본 경로: 사용자 본인의 RISS 검색 API 키로 상류(`https://www.riss.kr/openApi`)를 직접 호출한다. 이 스킬은 `k-skill-proxy`를 사용하지 않는다.
2. 상류 계약: `key`, `version=1.0`, `type`별 XML, `rsnum`, `rowcount(최대 100)`
3. 공식 문서: RISS API 센터(<https://www.riss.kr/apicenter/apiMain.do>)의 학위논문/국내학술논문/해외학술논문/단행본/연구보고서/학술지 검색 API 정보
### RISS API 키 발급
RISS 검색 Open API 키는 **공익 목적의 비영리 기관/대학에만** 무료로 발급된다(향후 민간 확대 예정). data.go.kr 키와 완전히 별개다.
1. RISS(<https://www.riss.kr>)에 기관 소속으로 회원가입/로그인
2. RISS API 센터 → **RISS 검색 API** 선택 → 이용신청(<https://riss.kr/openAPI/OpenApiRegisterFinal.do>)
3. KERIS 담당자 심사·승인 후 인증키(`key` 파라미터) 발급
4. 발급받은 키를 `KSKILL_RISS_API_KEY`(호환 `RISS_API_KEY`) 환경변수 또는 `~/.config/k-skill/secrets.env`에 설정
공공데이터포털 데이터셋 `15071949`는 RISS 종합목록의 정적 파일/카탈로그 현황 관련 표면이다. 기사·논문 검색 API가 아니므로 검색 fallback으로 사용하지 않는다. 데이터셋 `3046254`는 관련 카탈로그 항목이지만 실제 검색 계약은 RISS API 센터와 `/openApi`를 기준으로 한다.
## Commands
```bash
npx -y @nomadamas/k-skill@0 exec keris-academic-search scripts/keris_academic.py -- search --keyword '인공지능 교육'
npx -y @nomadamas/k-skill@0 exec keris-academic-search scripts/keris_academic.py -- search --title '대학도서관' --author '김연구' --resource-type T
npx -y @nomadamas/k-skill@0 exec keris-academic-search scripts/keris_academic.py -- search --keyword '한국어 교육' --resource-type D --page 2 --page-size 20 --json
```
검색 필드는 `keyword`, `title`, `author`, `subject`, `publisher`이며 하나 이상 필요하다. `page`는 1 이상, `pageSize`는 1~100이다. RISS의 `rsnum`은 `(page-1)*pageSize+1`로 계산한다. 실제 호출에는 사용자 RISS 키가 필요하다.
여러 upstream type을 합치는 `ALL`과 `A`는 첫 페이지에서 type별 결과를 round-robin으로 합친다. 정확한 후속 페이지가 필요하면 `T`, `D`, `B` 또는 실제 단일 자료유형 검색을 사용한다. 결합 검색은 upstream을 type 수만큼 호출한다.
사용자 resource type alias:
- `ALL`: 공식 `T/A/O/U/F/S`를 모아 최대 `pageSize`건 반환
- `T`: 학위논문, 공식 `T`
- `A`: 국내+해외 학술논문, 공식 `A/O`
- `D`: 국내 학술논문(domestic article) 호환 alias, 공식 `A`
- `B`: 단행본(book) 호환 alias, 공식 `U`
응답 item의 `resource_type`에는 alias가 아니라 실제 RISS 코드를 보존한다.
## Credentials
```bash
npx -y @nomadamas/k-skill@0 exec keris-academic-search scripts/keris_academic.py -- search --keyword '교육 데이터' --resource-type ALL --dry-run
```
- RISS 키: `KSKILL_RISS_API_KEY`, 없으면 compatibility `RISS_API_KEY`, 이후 `~/.config/k-skill/secrets.env`
- `DATA_GO_KR_API_KEY`는 RISS 검색에 사용하지 않는다
- `--dry-run`은 키를 발급받기 전에도 호출 URL을 확인할 수 있으며 키를 `REDACTED`로 표시한다
- 키가 없으면 실행을 중단하고 RISS API 센터 발급 안내를 출력한다
## Output and fallback order
1. 검색 성공 결과를 제목, 저자, 발행처/학술지, 연도, 링크, 원문 상태로 요약한다.
2. 키가 없으면 RISS API 센터에서 검색 API 키를 발급받도록 안내한다.
3. RISS 장애/쿼터/파싱 오류를 웹 scraping이나 data.go.kr `15071949`로 대체하지 않는다.
4. 빈 결과는 성공(`items: []`)으로 유지하고 검색어/자료유형을 조정한다.
## Failure modes
- missing key: 실행 중단 + RISS API 센터 키 발급 안내(exit 1)
- invalid input: 입력 검증 오류 메시지(exit 1)
- RISS 인증 오류 또는 HTTP 401/403: 키/기관 권한 확인 안내(exit 1)
- 호출량/쿼터 초과 또는 HTTP 429: 쿼터 초과 안내(exit 1)
- upstream network/HTTP failure: 네트워크/HTTP 오류 메시지(exit 1)
- empty/malformed/unexpected XML: XML 파싱 오류 메시지(exit 1)
- empty result: 성공 응답과 빈 `items`
- login/licensed full text: 상세 링크만 제공하고 자동 다운로드하지 않음
## Done when
- 검색 조건과 실제 RISS 자료유형이 명시됐다.
- 결과 또는 typed failure를 제공했다.
- 원문 유무 표시는 API 값으로만 설명했고 접근권한을 보장하거나 다운로드를 자동화하지 않았다.
references/DISCLAIMER.md
# DISCLAIMER — `keris-academic-search`
이 스킬은 KERIS·RISS 또는 자료 제공기관의 공식 기능 또는 공식 지원 도구가 아니며, 공식 제휴·후원·승인·인증 또는 협업한 사실이 전혀 없습니다. 관련 상표와 서비스명은 학술정보 조회 기능과 대상을 설명하기 위해서만 사용합니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 기능 설명용 표장과 출처표시를 구별했습니다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)에 따른 출처 혼동 판단은 별도입니다.
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)은 공개정보 수집 사안의 객관적 접근 제한을 기준으로 판단했으나, 기관·비영리 자격으로 발급된 API 키의 범위를 넘는 사용이나 학술 원문 다운로드를 허용하지 않습니다. 공개되어 누구나 얻을 수 있는 메타데이터의 기계적 수집만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아니지만 [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)의 데이터베이스 권리, 원문 저작권, API 계약과 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)은 별도입니다.
- 공개 메타데이터 자동 수집은 승인된 API 자격 범위에서 개인의 정보 조회용으로만 사용합니다.
- 조직적·대량 크롤링, 원문 수집, 별도 DB 구축·재배포를 하지 않습니다.
- 접근통제·차단·quota를 우회하거나 서비스 장애·영업 방해를 일으키지 않습니다.
이 문서는 적법성을 보증하는 법률 자문이 아닙니다.
references/TRADEMARK-LEGAL-STATEMENT.md
# 상표 사용 법적 고지 — `keris-academic-search`
이 스킬에서 `KERIS`와 `RISS` 명칭은 학술자료 메타데이터 조회 대상과 API 호환 대상을 식별하기 위해 사용합니다. k-skill의 출처를 해당 기관으로 표시하거나 공식·제휴·후원·승인·협업 관계를 주장하려는 사용이 아닙니다.
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 기능 또는 적용 대상을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우를 구별했습니다. 실제 표시 태양과 거래계 인식에 따라 결론이 달라질 수 있으므로 평문으로 필요한 범위에서만 사용하고 로고·공식·제휴·후원·인증 표현을 사용하지 않습니다.
공개정보 접근과 사용 제한을 포함한 전체 조건은 `npx -y @nomadamas/k-skill@0 read keris-academic-search references/DISCLAIMER.md`에서 확인합니다.
scripts/keris_academic.py
#!/usr/bin/env python3
"""Read-only KERIS/RISS academic metadata search helper using stdlib only.
RISS 검색 Open API는 기관/대학 전용 인증키를 요구하므로 k-skill-proxy를 거치지
않고, 사용자가 직접 발급받은 RISS 키로 상류(``https://www.riss.kr/openApi``)를
호출한다.
"""
from __future__ import annotations
import argparse
import json
import os
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional
DEFAULT_SECRETS_PATH = pathlib.Path("~/.config/k-skill/secrets.env").expanduser()
RISS_OPEN_API_URL = "https://www.riss.kr/openApi"
RESOURCE_TYPE_MAP = {"ALL": ["T", "A", "O", "U", "F", "S"], "T": ["T"], "A": ["A", "O"], "D": ["A"], "B": ["U"]}
MISSING_KEY_MSG = (
"RISS API 키가 없습니다. RISS API 센터(https://www.riss.kr/apicenter/apiMain.do)에서 "
"검색 API 키를 발급받아 KSKILL_RISS_API_KEY(호환 RISS_API_KEY) 환경변수 또는 "
"~/.config/k-skill/secrets.env 에 설정하세요. RISS 키는 비영리 기관/대학에 발급됩니다."
)
class HelperError(RuntimeError):
pass
def load_secrets(path: pathlib.Path) -> Dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return {}
values = {}
for raw in lines:
line = raw.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def resolve_api_key(args: argparse.Namespace) -> Optional[str]:
for name in ("KSKILL_RISS_API_KEY", "RISS_API_KEY"):
value = os.environ.get(name)
if value and value.strip():
return value.strip()
secrets = load_secrets(pathlib.Path(args.secrets_path).expanduser())
value = secrets.get("KSKILL_RISS_API_KEY") or secrets.get("RISS_API_KEY")
return value.strip() if value and value.strip() else None
def build_query(args: argparse.Namespace) -> Dict[str, Any]:
query = {}
for field in ("keyword", "title", "author", "subject", "publisher"):
value = getattr(args, field)
if value and value.strip():
text = value.strip()
if len(text) > 200 or any(ord(char) < 32 or ord(char) == 127 for char in text):
raise HelperError(f"{field} 값은 1~200자의 일반 텍스트여야 합니다.")
query[field] = text
if not query:
raise HelperError("--keyword, --title, --author, --subject, --publisher 중 하나 이상을 입력하세요.")
if not 1 <= args.page <= 100000:
raise HelperError("page 값은 1~100000 범위여야 합니다.")
if not 1 <= args.page_size <= 100:
raise HelperError("pageSize 값은 1~100 범위여야 합니다.")
if len(RESOURCE_TYPE_MAP[args.resource_type]) > 1 and args.page > 1:
raise HelperError("Combined resourceType searches support page 1 only; later pages require a single resource type.")
query.update({"resourceType": args.resource_type, "page": args.page, "pageSize": args.page_size})
return query
def build_riss_urls(query: Dict[str, Any], api_key: str) -> List[str]:
rsnum = ((query["page"] - 1) * query["pageSize"]) + 1
urls = []
for upstream_type in RESOURCE_TYPE_MAP[query["resourceType"]]:
params = {key: value for key, value in query.items() if key not in {"resourceType", "page", "pageSize"}}
params.update({"key": api_key, "version": "1.0", "type": upstream_type, "rsnum": rsnum, "rowcount": query["pageSize"]})
urls.append(f"{RISS_OPEN_API_URL}?{urllib.parse.urlencode(params)}")
return urls
def parse_riss_xml(raw: bytes) -> Dict[str, Any]:
try:
root = ET.fromstring(raw)
except ET.ParseError as error:
raise HelperError("RISS API 응답 XML이 올바르지 않습니다.") from error
if root.tag != "record":
raise HelperError("RISS API 응답 envelope가 올바르지 않습니다.")
error_text = root.findtext("./head/Error")
if error_text is None or not error_text.strip():
raise HelperError("RISS API 응답에 상태 메타데이터가 없습니다.")
code = error_text.strip()
message = (root.findtext("./head/ErrorMessage") or "Unknown RISS error").strip()
if code not in {"0", "000"}:
raise HelperError(f"RISS API 오류 {code}: {message}")
items = []
for metadata in root.findall("./metadata"):
values: Dict[str, List[str]] = {}
for child in metadata:
value = "".join(child.itertext()).strip()
if value:
values.setdefault(child.tag.removeprefix("riss.").lower(), []).append(value)
first = lambda key: values.get(key, [None])[0]
image, charge = (first("image") or "").upper(), first("charge")
available = True if image == "Y" else False if image == "N" else None
access = "free" if available and charge == "1" else "paid_or_restricted" if available and charge == "0" else "available" if available else "none" if available is False else "unknown"
author = first("author") or ""
authors = [part.strip() for part in author.replace(";", ",").split(",") if part.strip()] or ([author] if author else [])
items.append({"resource_type": first("type"), "title": first("title"), "authors": authors, "publisher": first("publisher"), "year": first("pubdate"), "publication": first("stitle"), "material_type": first("mtype"), "link": first("url"), "full_text_available": available, "full_text_access": access, "holdings": values.get("holdings", [])})
total_text = root.findtext("./head/totalcount")
try:
total_count = int(total_text) if total_text is not None else len(items)
except ValueError as error:
raise HelperError("RISS API 응답 totalcount가 올바른 정수가 아닙니다.") from error
if total_count < 0:
raise HelperError("RISS API 응답 totalcount가 음수입니다.")
return {"total_count": total_count, "items": items}
def http_get_riss_xml(urls: List[str], timeout: int, page: int, page_size: int) -> Dict[str, Any]:
results = []
for url in urls:
request = urllib.request.Request(url, headers={"accept": "application/xml", "user-agent": "k-skill/keris-academic-search"})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
results.append(parse_riss_xml(response.read()))
except urllib.error.HTTPError as error:
if error.code in (401, 403):
raise HelperError("RISS API가 키를 거부했습니다(HTTP 403). 발급받은 RISS 검색 API 키와 기관 권한을 확인하세요.") from error
if error.code == 429:
raise HelperError("RISS API 호출량/쿼터를 초과했습니다(HTTP 429).") from error
raise HelperError(f"RISS API 요청 실패: HTTP {error.code} {error.reason}") from error
except urllib.error.URLError as error:
raise HelperError(f"RISS API 네트워크 오류: {error.reason}") from error
except TimeoutError as error:
raise HelperError("RISS API 요청 시간이 초과되었습니다.") from error
queues = [list(result["items"]) for result in results]
items = []
while len(items) < page_size and any(queues):
for queue in queues:
if queue:
items.append(queue.pop(0))
if len(items) >= page_size:
break
return {"page": page, "page_size": page_size, "total_count": sum(result["total_count"] for result in results), "items": items}
def format_text(payload: Dict[str, Any]) -> str:
items = payload.get("items") or []
lines = [f"RISS 학술자료 검색 결과: {payload.get('total_count', len(items))}건"]
labels = {"free": "원문 있음(무료 표시)", "paid_or_restricted": "원문 있음(유료/기관권한 가능)", "available": "원문 있음(접근조건 확인 필요)", "none": "원문 없음", "unknown": "원문 여부 미상"}
for item in items:
lines.append(f"- {item.get('title') or '제목 없음'} / {', '.join(item.get('authors') or []) or '저자 미상'} / {item.get('publisher') or item.get('publication') or '발행처 미상'} / {item.get('year') or '연도 미상'} / {labels.get(item.get('full_text_access'), '원문 여부 미상')}")
if item.get("link"):
lines.append(f" {item['link']}")
if not items:
lines.append("조건에 맞는 학술자료가 없습니다.")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="KERIS/RISS 학술자료 메타데이터 검색")
search = parser.add_subparsers(dest="command", required=True).add_parser("search")
for field in ("keyword", "title", "author", "subject", "publisher"):
search.add_argument(f"--{field}")
search.add_argument("--resource-type", choices=RESOURCE_TYPE_MAP, default="ALL")
search.add_argument("--page", type=int, default=1)
search.add_argument("--page-size", type=int, default=10)
search.add_argument("--secrets-path", default=str(DEFAULT_SECRETS_PATH))
search.add_argument("--timeout", type=int, default=20)
search.add_argument("--dry-run", action="store_true")
search.add_argument("--json", action="store_true")
return parser
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
return build_parser().parse_args(argv)
def run(argv: Optional[List[str]] = None) -> int:
try:
args = parse_args(argv)
query = build_query(args)
if args.dry_run:
urls = build_riss_urls(query, "REDACTED")
print(json.dumps({"operation": "search", "urls": urls, "query": query}, ensure_ascii=False, indent=2))
return 0
api_key = resolve_api_key(args)
if not api_key:
raise HelperError(MISSING_KEY_MSG)
urls = build_riss_urls(query, api_key)
payload = http_get_riss_xml(urls, args.timeout, query["page"], query["pageSize"])
print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else format_text(payload))
return 0
except HelperError as error:
print(str(error), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(run())
skill.json
{
"name": "keris-academic-search",
"description": "Use when the user asks to search Korean theses, academic articles, research reports, journals, or university-library catalog metadata through KERIS RISS. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.",
"profiles": [
"proxy",
"vault",
"action:booking"
],
"frontmatter": "name: keris-academic-search\ndescription: Use when the user asks to search Korean theses, academic articles, research reports, journals, or university-library catalog metadata through KERIS RISS. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.\nlicense: MIT\nmetadata:\n category: research\n locale: ko-KR\n phase: v1"
}
SKILL.md
---
name: keris-academic-search
description: Use when the user asks to search Korean theses, academic articles, research reports, journals, or university-library catalog metadata through KERIS RISS. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.
license: MIT
metadata:
category: research
locale: ko-KR
phase: v1
---
# keris-academic-search
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct keris-academic-search
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files keris-academic-search
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/keris-academic-search/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read keris-academic-search references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.
tests/test_keris_academic.py
import contextlib
import importlib.util
import io
import json
import os
import pathlib
import unittest
import urllib.parse
from unittest import mock
ROOT = pathlib.Path(__file__).resolve().parents[2]
MODULE_PATH = ROOT / "keris-academic-search" / "scripts" / "keris_academic.py"
SPEC = importlib.util.spec_from_file_location("keris_academic", MODULE_PATH)
assert SPEC is not None
assert SPEC.loader is not None
keris_academic = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(keris_academic)
class KerisAcademicHelperTests(unittest.TestCase):
def test_single_type_pagination_command_contract(self):
stdout = io.StringIO()
seen = []
def fake_http(urls, timeout, page, page_size):
del timeout
seen.append((urls, page, page_size))
return {"page": page, "page_size": page_size, "total_count": 0, "items": []}
with mock.patch.dict(os.environ, {"KSKILL_RISS_API_KEY": "key"}, clear=True), \
mock.patch.object(keris_academic, "http_get_riss_xml", side_effect=fake_http), \
contextlib.redirect_stdout(stdout):
code = keris_academic.run([
"search", "--keyword", "한국어 교육", "--resource-type", "D",
"--page", "2", "--page-size", "20", "--json",
])
self.assertEqual(code, 0)
urls, page, page_size = seen[0]
self.assertEqual(len(urls), 1)
self.assertEqual((page, page_size), (2, 20))
params = urllib.parse.parse_qs(urllib.parse.urlparse(urls[0]).query)
self.assertEqual(params["type"], ["A"])
self.assertEqual(params["rowcount"], ["20"])
self.assertEqual(params["rsnum"], ["21"])
self.assertEqual(json.loads(stdout.getvalue())["page"], 2)
def test_build_query_strict_pagination_for_combined_types(self):
combined = keris_academic.parse_args([
"search", "--keyword", "교육", "--resource-type", "ALL", "--page", "2"
])
with self.assertRaisesRegex(keris_academic.HelperError, "[Cc]ombined resourceType"):
keris_academic.build_query(combined)
def test_riss_url_uses_riss_key_only_and_maps_book_alias(self):
args = keris_academic.parse_args([
"search", "--keyword", "도서관", "--resource-type", "B",
])
urls = keris_academic.build_riss_urls(keris_academic.build_query(args), "secret +/==")
self.assertEqual(len(urls), 1)
params = urllib.parse.parse_qs(urllib.parse.urlparse(urls[0]).query)
self.assertEqual(params["key"], ["secret +/=="])
self.assertEqual(params["version"], ["1.0"])
self.assertEqual(params["type"], ["U"])
self.assertNotIn("serviceKey", params)
def test_key_resolution_never_uses_data_go_kr_key(self):
args = keris_academic.parse_args([
"search", "--keyword", "교육", "--secrets-path", "/tmp/missing-riss-secrets"
])
with mock.patch.dict(os.environ, {
"KSKILL_RISS_API_KEY": "primary",
"RISS_API_KEY": "compat",
"DATA_GO_KR_API_KEY": "wrong",
}, clear=True):
self.assertEqual(keris_academic.resolve_api_key(args), "primary")
with mock.patch.dict(os.environ, {"RISS_API_KEY": "compat", "DATA_GO_KR_API_KEY": "wrong"}, clear=True):
self.assertEqual(keris_academic.resolve_api_key(args), "compat")
with mock.patch.dict(os.environ, {"KSKILL_RISS_API_KEY": " ", "RISS_API_KEY": "compat"}, clear=True):
self.assertEqual(keris_academic.resolve_api_key(args), "compat")
with mock.patch.dict(os.environ, {"DATA_GO_KR_API_KEY": "wrong"}, clear=True):
self.assertIsNone(keris_academic.resolve_api_key(args))
def test_missing_key_names_riss_variables_and_issuance(self):
stderr = io.StringIO()
with mock.patch.dict(os.environ, {}, clear=True), contextlib.redirect_stderr(stderr):
code = keris_academic.run([
"search", "--keyword", "교육", "--secrets-path", "/tmp/missing-riss-secrets"
])
self.assertEqual(code, 1)
output = stderr.getvalue()
self.assertIn("KSKILL_RISS_API_KEY", output)
self.assertIn("RISS_API_KEY", output)
self.assertIn("apicenter", output)
self.assertNotIn("DATA_GO_KR_API_KEY", output)
def test_timeout_is_reported_without_traceback(self):
stderr = io.StringIO()
with mock.patch.dict(os.environ, {"KSKILL_RISS_API_KEY": "key"}, clear=True), mock.patch.object(
keris_academic.urllib.request, "urlopen", side_effect=TimeoutError("timed out")
), contextlib.redirect_stderr(stderr):
code = keris_academic.run(["search", "--keyword", "교육", "--resource-type", "T"])
self.assertEqual(code, 1)
self.assertIn("시간이 초과", stderr.getvalue())
self.assertNotIn("Traceback", stderr.getvalue())
def test_forbidden_key_maps_to_readable_error(self):
stderr = io.StringIO()
http_error = keris_academic.urllib.error.HTTPError("u", 403, "Forbidden", {}, None)
with mock.patch.dict(os.environ, {"KSKILL_RISS_API_KEY": "key"}, clear=True), mock.patch.object(
keris_academic.urllib.request, "urlopen", side_effect=http_error
), contextlib.redirect_stderr(stderr):
code = keris_academic.run(["search", "--keyword", "교육", "--resource-type", "T"])
self.assertEqual(code, 1)
self.assertIn("403", stderr.getvalue())
self.assertNotIn("Traceback", stderr.getvalue())
def test_dry_run_redacts_key_for_all_resource_types_without_key_env(self):
stdout = io.StringIO()
with mock.patch.dict(os.environ, {}, clear=True), contextlib.redirect_stdout(stdout):
code = keris_academic.run([
"search", "--keyword", "교육", "--resource-type", "ALL", "--dry-run"
])
self.assertEqual(code, 0)
output = stdout.getvalue()
self.assertIn("REDACTED", output)
payload = json.loads(output)
self.assertGreaterEqual(len(payload["urls"]), 4)
def test_text_summary_includes_metadata_and_full_text_state(self):
payload = {
"total_count": 1,
"items": [{
"title": "인공지능 교육 연구",
"authors": ["김연구", "이학술"],
"publisher": "한국교육학회",
"year": "2025",
"link": "https://www.riss.kr/link?id=A123",
"full_text_available": True,
"full_text_access": "free",
}],
}
text = keris_academic.format_text(payload)
self.assertIn("인공지능 교육 연구", text)
self.assertIn("김연구, 이학술", text)
self.assertIn("원문 있음(무료 표시)", text)
self.assertIn("https://www.riss.kr/link?id=A123", text)
def test_riss_xml_requires_explicit_error_status(self):
with self.assertRaisesRegex(keris_academic.HelperError, "상태"):
keris_academic.parse_riss_xml(b"<record><head><totalcount>0</totalcount></head></record>")
with self.assertRaisesRegex(keris_academic.HelperError, "envelope"):
keris_academic.parse_riss_xml(b"<foo><head><totalcount>0</totalcount><Error>0</Error></head></foo>")
with self.assertRaisesRegex(keris_academic.HelperError, "totalcount"):
keris_academic.parse_riss_xml(b"<record><head><totalcount>many</totalcount><Error>0</Error></head></record>")
def test_result_supports_json_output(self):
payload = {"page": 1, "page_size": 10, "total_count": 0, "items": []}
stdout = io.StringIO()
with mock.patch.dict(os.environ, {"KSKILL_RISS_API_KEY": "key"}, clear=True), \
mock.patch.object(keris_academic, "http_get_riss_xml", return_value=payload), \
contextlib.redirect_stdout(stdout):
code = keris_academic.run(["search", "--keyword", "없는검색어", "--json"])
self.assertEqual(code, 0)
self.assertEqual(json.loads(stdout.getvalue())["items"], [])
if __name__ == "__main__":
unittest.main()