dev → main: srt-booking 좌석 탐색, korean-humanizer 신규 스킬, toss-securities 공식 OpenAPI 클라이언트, korean-law k-skill-proxy 편입 (#314)

* feat(srt-booking): SRT 좌석 확인과 탐색 우선순위 개선 (#305)

* feat(srt): 좌석 조회와 탐색 우선순위 추가

SRT search 결과의 stable train_id로 객차별 좌석을 조회하고, 특정 호차/좌석 확인과 탐색 우선순위 옵션을 제공한다.

Constraint: SRT와 KTX는 별도 upstream 표면이므로 SRT HTML 파서와 테스트를 분리함
Rejected: KTX 좌석 helper 공유 | Korail API와 SRT 웹 좌석선택 HTML 계약이 달라 혼용하면 파서 안정성이 낮아짐
Confidence: medium
Scope-risk: moderate
Directive: SRT 좌석선택 HTML에서 노출되지 않는 속성은 추정하지 말고 명시적으로 처리할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_srt_booking scripts.test_ktx_booking; python3 -m py_compile scripts/srt_booking.py scripts/srt_seats.py scripts/test_srt_booking.py
Not-tested: 실제 예약 API에 우선순위 좌석 선택을 연결하는 흐름

* fix(srt): 좌석 조회 JSON 출력 안정화

SRT 대기열 메시지가 stdout에 섞여 seats JSON을 깨는 실제 표면 문제를 막고, 누락된 좌석 방향/위치 속성을 unknown으로 정규화한다.

Constraint: issue #303 범위는 예약 부작용이 없는 좌석 조회 보조 흐름으로 제한됨
Rejected: 실제 예약 subcommand 추가 | 좌석 선점/예약은 외부 부작용이라 이번 acceptance criteria에 포함되지 않음
Confidence: high
Scope-risk: narrow
Directive: SRTrain upstream 출력이 추가되더라도 helper stdout은 JSON 전용으로 유지할 것
Tested: RED→GREEN in .omo/ulw-loop/evidence/srt-c002-red-green-tests.txt; live SRT tmux QA in .omo/ulw-loop/evidence/srt-c001-live-search-seats.txt; npm run ci in .omo/ulw-loop/evidence/srt-c003-regression-ci.txt
Not-tested: 실제 예약/결제/취소 부작용 흐름

* test(srt): split seat helper regression coverage

---------

Co-authored-by: Jeffrey (Dongkyu) Kim <vkehfdl1@gmail.com>

* feat: add korean-humanizer skill

AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고치는 프롬프트 기반 스킬.
blader/humanizer의 구조·방법론(패턴 카탈로그 + draft→audit→final 루프 +
false positive 가이드)을 한국어에 맞게 재구성했다.

- 한국어 특화 33개 패턴: 번역체(직역 조사·무생물 주어·"~들"·"가지다"·이중피동·
  명사화), AI 상투어, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재,
  줄표·가운뎃점·곡선따옴표 등
- Triage(최소 개입) 원칙: 서식만 문제면 산문은 그대로 두어 과교정 방지
- Length control: 목표 글자수 지정 시 ±5% 내로 맞추고 공백 포함/제외 수치 보고,
  korean-character-count 스킬과 연동

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

* feat(korean-humanizer): rebuild v2 on im-not-ai framework

Build on happy-nut's PR #311 korean-humanizer skill (cherry-picked,
authorship preserved) by re-centering it on the epoko77-ai/im-not-ai
(Humanize KR, MIT) methodology:

- 4대 철칙 (의미 불변 · 근거 기반 · 장르 유지 · 과윤문 금지 30%/50% 가드)
- S1/S2/S3 severity tiers and A~D quality grades
- A~J taxonomy with Korean-specific patterns (A-16 그/그녀 강박,
  A-18 관계절 좌향 수식, A-19 이중 조사, C-11 연결어미 뒤 쉼표, E-7 경어법)
- detect -> rewrite -> audit -> grade loop with self-check checklist
- references/ai-tell-taxonomy.md full A~J table
- docs/features/korean-humanizer.md crediting im-not-ai and happy-nut
- README row + link, regenerated plugin.json, docs regression test

Co-authored-by: happy-nut <happynut.dev@gmail.com>

* docs(korean-law-search): document official precedent API evidence (#313)

Enhance the existing korean-law-search skill and feature doc with the
official 법제처 Open API precedent endpoints and detail retrieval, without
adding a new skill, package, workspace, or changeset.

- Document 판례 목록 조회 (lawSearch.do?target=prec) and 판례 본문 조회
  (lawService.do?target=prec&ID=...) as official evidence behind the
  korean-law-mcp search_precedents/get_precedent_text path.
- Add supported precedent filters (query, court, case number, source
  name, date, sort) and precedent-specific failure modes (missing LAW_OC,
  upstream unavailable/rate-limit/timeout, empty results, body
  unavailable for some sources) plus the legal-advice boundary.
- Keep korean-law-mcp first and Beopmang as the only post-failure
  fallback; lawService.do?target=prec is official detail retrieval, not a
  Beopmang-style fallback.
- Extend the skill-docs regression test with stable endpoint/tool
  literals and concept-level filter/failure-mode/legal-boundary checks.

Closes #308

* feat(toss-securities): add official read-only OpenAPI client (#312)

Add an official Toss Securities Open API client alongside the existing
unofficial tossctl wrapper. The package ships read-only helpers backed by
the official REST API (https://openapi.tossinvest.com): OAuth2
client_credentials token issuance with an in-memory token cache, bearer +
X-Tossinvest-Account header handling, TossApiError/TossCredentialsError
with secret/token redaction, and 429 Retry-After/backoff retry.

Credentials are read from TOSSINVEST_CLIENT_ID/TOSSINVEST_CLIENT_SECRET
(optional TOSSINVEST_ACCOUNT/TOSSINVEST_API_BASE_URL) and sent directly to
Toss, never through a shared proxy. Order mutation remains out of scope;
the tossctl path is retained as a documented fallback.

Closes #306

* Revert "docs(korean-law-search): document official precedent API evidence (#313)"

This reverts commit 5faec8bb2a.

* feat(k-skill-proxy): fold Korean law lookups into k-skill-proxy, drop Beopmang (#315)

Add hosted korean-law proxy routes and make the korean-law-search skill
proxy-first, removing the unstable Beopmang fallback from the support list.

- proxy: new src/korean-law.js wrapping official 법제처 DRF lawSearch.do /
  lawService.do, injecting LAW_OC + browser User-Agent/Referer (the real
  cause of "사용자 정보 검증 실패") and retrying empty/HTML responses.
- proxy: /v1/korean-law/search and /v1/korean-law/detail routes + lawOc
  config + koreanLawConfigured health flag; 17 module + 6 route tests.
- skill/docs: korean-law-search becomes proxy-first (no per-user LAW_OC,
  no local CLI). Drop Beopmang everywhere; credit chrisryugj/korean-law-mcp
  as design reference and 법제처 open.law.go.kr as official source.
- ops: LAW_OC added to deploy doc KEYS, secret accessor loop, and the
  Cloud Run deploy workflow set-secrets.
- changeset: k-skill-proxy minor.

---------

Co-authored-by: iamiks <rmstjr1030@naver.com>
Co-authored-by: happy-nut <happynut.dev@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-06-12 19:06:18 +09:00 committed by GitHub
commit 66f12cb43d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3708 additions and 428 deletions

View file

@ -0,0 +1,5 @@
---
"k-skill-proxy": minor
---
Add hosted `korean-law` proxy routes (`/v1/korean-law/search`, `/v1/korean-law/detail`) that wrap the official 법제처 (open.law.go.kr) DRF `lawSearch.do`/`lawService.do` endpoints. The proxy injects the operator `LAW_OC` plus a browser `User-Agent`/`Referer` (the actual cause of upstream "사용자 정보 검증 실패" rejections) and retries empty/HTML maintenance responses, so the `korean-law-search` skill becomes proxy-first with no per-user key. Drops the unstable Beopmang fallback from the documented surface.

View file

@ -0,0 +1,5 @@
---
"toss-securities": minor
---
Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.

View file

@ -53,6 +53,7 @@
"./korea-weather",
"./korean-character-count",
"./korean-cinema-search",
"./korean-humanizer",
"./korean-jangbu-for",
"./korean-law-search",
"./korean-marathon-schedule",

View file

@ -88,6 +88,7 @@ jobs:
KOSIS_API_KEY=KOSIS_API_KEY:latest
NAVER_SEARCH_CLIENT_ID=NAVER_SEARCH_CLIENT_ID:latest
NAVER_SEARCH_CLIENT_SECRET=NAVER_SEARCH_CLIENT_SECRET:latest
LAW_OC=LAW_OC:latest
env_vars: |-
KSKILL_PROXY_HOST=0.0.0.0
KSKILL_PROXY_NAME=k-skill-proxy

View file

@ -73,7 +73,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| KBL 경기 결과 조회 | `kbl-results` | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
| K리그 경기 결과 조회 | `kleague-results` | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| LCK 경기 분석 | `lck-analytics` | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 토스증권 조회 | `toss-securities` | 토스증권 공식 Open API(OAuth2) 우선, tossctl fallback으로 계좌·보유주식·시세·주문조회 등 조회 전용 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
| 하이패스 영수증 발급 | `hipass-receipt` | 하이패스 사용내역 조회 및 영수증 출력 payload 준비 | 필요 | [하이패스 영수증 발급 가이드](docs/features/hipass-receipt.md) |
| 캐치테이블 예약 스나이핑 | `catchtable-sniper` | 로그인된 캐치테이블 Chrome 세션으로 빈자리 감시, 오픈런, 자동 예약 시도 | 필요 | [캐치테이블 예약 스나이핑 가이드](docs/features/catchtable-sniper.md) |
| 공연 일정·잔여석 조회 | `ticket-availability` | YES24·인터파크 공연의 회차별 일정과 등급별 잔여석 수를 단일 HTTP 호출로 조회 (조회 전용, 예매·결제 없음) | 불필요 | [공연 일정·잔여석 조회 가이드](docs/features/ticket-availability.md) |
@ -107,6 +107,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
| 한국어 AI 윤문 | `korean-humanizer` | AI가 쓴 티 나는 한국어 글을 번역체·AI 상투어·과장된 의의·줄표/이모지 등 흔적을 심각도(S1/S2/S3)로 분류해 의미는 보존하며 사람 글로 윤문, 목표 글자수도 맞춤 | 불필요 | [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md) |
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
@ -228,6 +229,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
- [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md)
- [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md)
- [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md)
- [릴리스/배포 가이드](docs/releasing.md)

View file

@ -119,7 +119,7 @@ for s in \
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET; do
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC; do
gcloud secrets add-iam-policy-binding "$s" \
--project="$PROJECT_ID" \
--member="serviceAccount:${RUNTIME_SA}" \
@ -159,7 +159,7 @@ KEYS=(
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC
)
set -a; source ~/.config/k-skill/secrets.env; set +a

View file

@ -0,0 +1,60 @@
# 한국어 AI 윤문 (korean-humanizer) 가이드
## 이 기능으로 할 수 있는 일
- ChatGPT·Claude·Gemini 등이 쓴 "AI 티 나는" 한국어 글을 자연스러운 사람 글로 윤문
- 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·이모지·곡선따옴표 같은 흔적을 **심각도(S1/S2/S3)** 로 분류해 탐지
- "이 글에서 AI 흔적 찾아줘"처럼 고치지 않고 진단만 (탐지 리포트 + 심각도)
- 목표 글자수 지정 시(`length=1000`, "1000자로") ±5% 안으로 분량 조정, 공백 포함/제외 글자수 보고
- 사용자 글 샘플을 주면 그 말투(voice)로 재작성
## 왜 별도 스킬이 필요한가
- 영어권 humanizer(QuillBot·Undetectable AI 등)는 한국어에 약하다. 한국어 AI 글의 티는 대부분 **영어 번역투**와 격식을 가장한 **상투어**에서 나온다.
- 단순 맞춤법 교정(`korean-spell-check`)이나 유행어 입히기(`korean-slang-writing`)와 달리, 이 스킬은 의미를 보존하면서 **문체·리듬·표현**만 사람답게 되돌린다.
- 과교정을 막기 위해 4대 철칙(의미 불변 · 근거 기반 · 장르 유지 · 과윤문 금지)과 변경률 가드(30% 경고, 50% 중단)를 둔다.
## 먼저 필요한 것
- 추가 설치·API 키 없음. 이 스킬은 프롬프트/지식 기반이며 외부 호출이나 스크립트가 없다.
- (선택) 정확한 글자수 카운팅이 필요하면 `korean-character-count` 스킬과 연동된다.
## 기본 흐름 (탐지 → 윤문 → 감사 → 등급)
1. **트리아지** — 흔적이 무더기인지, 서식만 문제인지, 산문까지 다시 써야 하는지 먼저 정한다.
2. **탐지** — A~J 분류 카탈로그로 흔적을 span·심각도로 표시한다. S1부터 본다.
3. **윤문** — 흔적을 자연스러운 표현으로 교체한다. 의미·사실·고유명사·수치는 100% 보존한다.
4. **감사** — "왜 아직 AI 같은가?"를 다시 묻고, 자가검증 6항과 변경률을 점검한다. 위반이면 롤백 후 재윤문.
5. **등급** — A~D로 자가 채점한다. C·D면 추가 윤문이나 사람 검토를 권한다.
전체 패턴 표(A~J, 60+ 서브 패턴)는 스킬 디렉터리의 [`references/ai-tell-taxonomy.md`](../../korean-humanizer/references/ai-tell-taxonomy.md)에 있다.
## 사용 예시
```text
이 글 AI 티 안 나게 자연스럽게 다듬어줘:
[ChatGPT/Claude 초안 붙여넣기]
```
```text
이 글에서 AI 흔적만 찾아줘 (고치지 말고 심각도까지)
```
```text
1000자로 맞춰서 번역체 고쳐줘
```
## 제한사항
- 문체만 고친다. 사실관계 확인·출처 보강은 하지 않는다(필요하면 별도 리서치).
- 원문에 없는 내용을 창작해 채우지 않는다(의미 보존이 원칙).
- 변경률이 50%를 넘으면 작업을 중단하고 사람 검토를 권한다.
## 감사의 말 (Acknowledgments)
이 스킬은 두 기여 위에 만들어졌다.
- **[happy-nut](https://github.com/happy-nut) (Hyungsun Song)** 님이 PR [#311](https://github.com/NomaDamas/k-skill/pull/311)로 최초 `korean-humanizer` 스킬과 33개 한국어 패턴 카탈로그·예문, triage/length-control 설계를 기여했다. 이 가이드와 v2 스킬의 토대다.
- **[epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai)** (Humanize KR, MIT)의 방법론을 중심으로 v2를 재구성했다. A~J 분류 체계, S1/S2/S3 심각도, 4대 철칙, 변경률 30%/50% 가드, 품질 등급(A~D), 그리고 A-16(그/그녀 강박)·A-18(관계절 좌향 수식)·A-19(이중 조사)·C-11(연결어미 뒤 쉼표)·E-7(경어법 일관성) 같은 한국어 고유 패턴이 여기서 왔다.
원형은 영어권 [blader/humanizer](https://github.com/blader/humanizer)와 [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing)이다. 두 프로젝트와 happy-nut 님의 기여에 감사한다.

View file

@ -2,126 +2,101 @@
## 이 기능으로 할 수 있는 일
- `korean-law-mcp` 로 법령명 검색
- 특정 법령의 조문 본문 조회
- 판례 / 유권해석 / 자치법규 검색
- MCP 또는 CLI 경로 중 현재 환경에 맞는 방식 선택
- 기존 경로 장애 시 `법망` fallback으로 이어가기
- `k-skill-proxy` 로 법령명/조문/판례/유권해석/자치법규 검색
- 검색 결과 식별자로 조문·판례 본문(상세) 조회
- 별도 API key나 로컬 설치 없이 hosted proxy로 바로 사용
## 가장 중요한 규칙
한국 법령 관련 검색/조회가 필요할 때는 **`korean-law-mcp`를 먼저 사용**합니다.
기존 서비스가 동작하지 않을 때만 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 전환합니다.
별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
한국 법령 관련 검색/조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint로 처리합니다. 사용자 쪽 `LAW_OC` 가 불필요합니다. 별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
이 endpoint는 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 감싼 것이고, read-only 도구 표면 설계는 `chrisryugj/korean-law-mcp` 를 참고했습니다.
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
- remote MCP endpoint를 쓸 MCP 클라이언트
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
- (선택) `KSKILL_PROXY_BASE_URL` — self-host proxy를 쓸 때만
무료 API key 발급처: `https://open.law.go.kr`
사용자는 별도 API key를 준비할 필요가 없습니다. upstream `LAW_OC` 는 proxy 서버에서만 주입합니다. 무료 발급처(운영자용): `https://open.law.go.kr`
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
## 기본 경로
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
## 지원 endpoint
### 검색/목록 조회
```
GET /v1/korean-law/search?target={target}&query={검색어}
```
| target | 설명 |
|---|---|
| `law` | 현행법령 |
| `eflaw` | 시행일 법령 |
| `prec` | 판례 |
| `detc` | 헌재결정례 |
| `expc` | 법령해석례(유권해석) |
| `admrul` | 행정규칙 |
| `ordin` | 자치법규 |
| `trty` | 조약 |
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원) 등. 활성 필터만 넘기고, 요약 전에 반환 메타데이터를 확인합니다.
### 본문/상세 조회
```
GET /v1/korean-law/detail?target={target}&ID={일련번호}
```
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져옵니다. 조문 지정은 `JO`(예: `000200` = 제2조)로 넘깁니다.
## 예시
```bash
npm install -g korean-law-mcp
export LAW_OC=your-api-key
# 법령명 검색
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=law' \
--data-urlencode 'query=관세법'
korean-law list
korean-law help search_law
```
# 판례 검색
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=prec' \
--data-urlencode 'query=부당해고'
로컬 설치가 막히면 먼저 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 사용한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용한다.
## MCP 연결 예시
```json
{
"mcpServers": {
"korean-law": {
"command": "korean-law-mcp",
"env": {
"LAW_OC": "your-api-key"
}
}
}
}
```
remote endpoint 예시:
```json
{
"mcpServers": {
"korean-law": {
"url": "https://korean-law-mcp.fly.dev/mcp"
}
}
}
```
위 remote 예시는 upstream 문서 기준으로 사용자 `LAW_OC` 를 따로 넣지 않는다. 사용자 쪽에서 준비할 것은 `url` 등록뿐이다.
## fallback: 법망
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 `법망`을 사용한다.
### MCP fallback
```json
{
"mcpServers": {
"beopmang": {
"url": "https://api.beopmang.org/mcp"
}
}
}
```
### REST fallback 예시
```bash
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
# 판례 본문 조회
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
--data-urlencode 'target=prec' \
--data-urlencode 'ID=228541'
```
## 기본 흐름
1. 질의가 법령/판례/행정해석/자치법규 중 어디에 가까운지 분류한다.
2. 법령명만 찾으면 `search_law` 를 먼저 쓴다.
3. 특정 조문이 필요하면 `search_law` 또는 `search_all` 로 식별자(`mst`)를 확인한 뒤 `get_law_text` 를 호출한다.
4. 판례는 `search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
5. 범주가 애매하면 `search_all` 로 시작한다.
6. `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 막히면 `법망` fallback으로 전환한다.
7. fallback 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
2. 법령명만 찾으면 `target=law``search` 한다.
3. 특정 조문이 필요하면 `search` 로 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 을 호출한다.
4. 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
5. 범주가 애매하면 `target=law` 부터 시작한다.
6. 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
## CLI 예시
## 실패 모드
```bash
korean-law search_law --query "관세법"
korean-law get_law_text --mst 160001 --jo "제38조"
korean-law search_precedents --query "부당해고"
```
- `target` 이 없거나 허용되지 않은 값이면 400 응답
- 검색어/식별자가 없으면 400 응답
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
- 법제처 API가 사용자 검증 실패를 반환하면 502 + `law_user_verification_failed` (운영자가 서버 OC/UA/Referer 점검)
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다.
## 운영 팁
- `화관법` 같은 약칭은 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
- 조문 번호가 헷갈리면 `get_law_text` 전에 법령 식별자부터 다시 확인한다.
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보를 안내한다.
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
- 기존 `korean-law-mcp` 경로가 실패하면 `https://api.beopmang.org/mcp` 또는 `/api/v4/law?action=search` 경로를 fallback으로 쓴다.
- `화관법` 같은 약칭은 `target=law` 로 정식 법령명을 먼저 확인한다.
- 조문 번호가 헷갈리면 `detail` 전에 법령 식별자부터 다시 확인한다.
- 요약은 할 수 있지만 법률 자문처럼 단정적으로 결론을 내리지는 않는다.
## 라이브 확인 메모
## 출처
2026-04-01 기준 smoke test 에서 아래 명령은 실제로 정상 동작했다.
- `korean-law list`
- `korean-law help search_law`
즉, `korean-law-mcp` CLI 설치와 기본 명령 진입은 검증했다. 실제 법령 검색은 로컬 CLI/MCP 경로라면 `LAW_OC` 가 준비된 환경에서 바로 이어서 사용할 수 있고, remote MCP endpoint는 사용자 `LAW_OC` 없이 URL 등록만으로 붙일 수 있다. 기존 경로 장애 시에는 `법망` fallback을 사용할 수 있다.
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
- 공식 데이터 출처: 법제처 국가법령정보 공동활용 (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요)

View file

@ -4,6 +4,8 @@
- 수서 출발 SRT 열차 조회
- 좌석 가능 여부 확인
- 호차별 남은 좌석번호 확인
- 특정 좌석 공석 여부 확인
- 예약 진행
- 예약 내역 확인
- 예약 취소
@ -35,33 +37,57 @@
- 희망 시작 시각: `HHMMSS`
- 인원 수
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 좌석 번호, 빈 좌석만 보기, 탐색 우선순위
## 기본 흐름
1. `SRTrain` 패키지가 없으면 다른 방법으로 우회하지 말고 먼저 전역 설치합니다.
2. `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` 가 없으면 credential resolution order에 따라 확보합니다.
3. 먼저 열차를 조회합니다.
3. 먼저 helper 로 열차를 조회합니다.
4. 후보 열차의 출발/도착 시각, 좌석 여부, 운임을 보여줍니다.
5. 대상 열차가 명확할 때만 예약합니다.
6. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
5. 사용자가 좌석번호, 호차별 잔여석, 특정 좌석 공석 여부를 물으면 `seats` 로 상세 좌석을 먼저 확인합니다.
6. 대상 열차가 명확할 때만 예약합니다.
7. 예약 확인/취소는 예약을 다시 식별한 뒤 진행합니다.
## 예시
```bash
python3 - <<'PY'
import os
from SRT import SRT
srt = SRT(os.environ["KSKILL_SRT_ID"], os.environ["KSKILL_SRT_PASSWORD"])
trains = srt.search_train("수서", "부산", "20260328", "080000", time_limit="120000")
for idx, train in enumerate(trains[:5], start=1):
print(idx, train)
PY
python3 scripts/srt_booking.py search 수서 부산 20260328 080000 --time-limit 120000 --limit 5
```
상세 좌석 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id>
```
특정 호차의 빈 좌석만 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --available-only
```
특정 좌석이 비었는지 확인:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --seat 11A
```
탐색 순서 조정:
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 \
--train-id <train_id> \
--car-priority center \
--seat-priority window-forward \
--available-only
```
`seats` 응답은 호차별 `available_seat_count`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 특정 좌석 요청 시 `requested_seat_available` 을 JSON 으로 반환합니다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 합니다.
## 주의할 점
- credential은 환경변수로 주입합니다.
- 상세 좌석 확인은 SRT 웹 좌석선택 페이지의 공개 HTML을 조회 전용으로 파싱합니다.
- 결제 완료까지 자동화하는 문서는 아닙니다.
- 매진 시 공격적인 재시도 루프는 피합니다.

View file

@ -1,23 +1,68 @@
# 토스증권 조회 가이드
토스증권 조회는 두 경로를 제공한다. **공식 Open API(OAuth2)를 우선** 사용하고, 공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다. 두 경로 모두 read-only(조회 전용)이며 실거래 mutation은 포함하지 않는다.
## 이 기능으로 할 수 있는 일
- `tossctl` 기반 토스증권 계좌 목록 / 계좌 요약 조회
- 포트폴리오 보유 종목 / 자산 비중 조회
- 단일 종목 / 다중 종목 시세 조회
- 미체결 주문 / 월간 체결 내역 조회
- 관심종목 목록 조회
- 공식 API: 계좌 목록 / 보유 주식 조회
- 공식 API: 시세(현재가·호가·체결·상하한가·캔들) / 종목 정보 / 매수 유의사항
- 공식 API: 환율(KRW↔USD) / 장 운영 캘린더(KR·US)
- 공식 API: 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
- tossctl fallback: 계좌 요약, 포트폴리오 보유 종목 / 자산 비중, 관심종목, 월간 체결 내역
## 먼저 필요한 것
## 1. 공식 Open API (권장)
- macOS + Homebrew
- `tossctl` 설치
- `tossctl auth login` 으로 브라우저 세션 확보
- `node` 18+
### 먼저 필요한 것
## upstream 설치와 로그인
- 토스증권 OpenAPI 콘솔에서 발급한 `client_id` / `client_secret`
- `node` 18+ (global `fetch`)
이 기능은 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다.
자격 증명은 사용자 환경변수로 두고 helper가 `https://openapi.tossinvest.com` 으로 직접 호출한다. 공유 프록시(k-skill-proxy)로 보내지 않는다.
| 환경변수 | 설명 |
|---|---|
| `TOSSINVEST_CLIENT_ID` | client id (필수) |
| `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
| `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
| `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
### 동작 방식
helper는 `POST /oauth2/token` 으로 Client Credentials access token을 발급받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. `429``Retry-After` 만큼 대기 후 백오프 재시도하고, `401` 은 토큰을 1회 재발급한다. `client_secret`/토큰은 에러에서 마스킹된다.
### Node.js 예시
```js
const {
getPrices,
listOfficialAccounts,
getHoldings,
getBuyingPower
} = require("toss-securities");
async function main() {
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
const buyingPower = await getBuyingPower({ account: accountSeq, currency: "KRW" });
console.log(prices.data);
console.log(holdings.data);
console.log(buyingPower.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 2. tossctl fallback
이 경로는 `JungHoonGhae/tossinvest-cli``tossctl` 을 그대로 사용한다. 공식 API credentials가 없을 때 쓴다.
```bash
brew tap JungHoonGhae/tossinvest-cli
@ -29,7 +74,7 @@ tossctl auth login
로그인이 끝나기 전에는 계좌/포트폴리오 조회를 시도하지 않는다.
## 지원하는 read-only 명령
지원하는 read-only 명령:
- `tossctl account list --output json`
- `tossctl account summary --output json`
@ -41,43 +86,17 @@ tossctl auth login
- `tossctl orders completed --market all --output json`
- `tossctl watchlist list --output json`
## Node.js 예시
```js
const {
getAccountSummary,
getPortfolioPositions,
getQuote,
listCompletedOrders
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary();
const positions = await getPortfolioPositions();
const quote = await getQuote("TSLA");
const completed = await listCompletedOrders({ market: "all" });
console.log(summary.data);
console.log(positions.data);
console.log(quote.data);
console.log(completed.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listCompletedOrders`, `listWatchlist` 등)도 동일하게 동작한다.
## 운영 팁
- 계좌 요약과 포트폴리오는 로그인 세션이 있어야만 동작한다.
- `TSLA`, `VOO`, `005930` 같이 심볼을 그대로 넘기면 된다.
- 공식 API는 `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` 가 있어야 동작하고, 계좌·자산·주문조회는 `X-Tossinvest-Account`(=`TOSSINVEST_ACCOUNT` 또는 `account` 옵션)가 필요하다.
- `005930`, `AAPL`, `TSLA` 같이 심볼을 그대로 넘기면 된다. 공식 `getPrices`/`getStocks` 는 다건 심볼을 콤마로 연결한다.
- 주문 관련 답변은 **조회 결과만** 정리하고, 실거래로 이어지는 행동은 권하지 않는다.
- 민감한 계좌 정보는 꼭 필요한 값만 답한다.
## 주의할 점
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다.
- 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 read-only wrapper 이며, 거래 mutation 명령은 공개 API에 포함하지 않는다.
- 공식 credentials가 없으면 helper가 `TossCredentialsError` 로 명확히 실패한다.
- `tossctl` 은 비공식 CLI 이므로 웹 내부 API 변경에 영향을 받을 수 있다. 브라우저 세션이 만료되면 `tossctl auth login` 을 다시 해야 할 수 있다.
- 이 레포의 `toss-securities` 패키지는 공식/비공식 모두 read-only 이며, 거래 mutation 명령(주문 생성/정정/취소)은 공개 API에 포함하지 않는다.

View file

@ -129,19 +129,7 @@ npx --yes skills add <owner/repo> \
--skill fine-dust-location
```
`korean-law-search` 는 skill 설치 후 upstream CLI/MCP도 준비해야 한다.
- 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운다.
- remote endpoint는 `LAW_OC` 없이 `url`만 등록한다.
- 기존 `korean-law-mcp` 경로가 실패하면 `법망`(`https://api.beopmang.org`) fallback을 사용한다.
```bash
npm install -g korean-law-mcp
export LAW_OC=your-api-key
korean-law list
```
로컬 설치가 막히면 `https://korean-law-mcp.fly.dev/mcp` remote endpoint를 MCP 클라이언트에 등록한다. 그 경로도 응답하지 않거나 서비스 장애가 나면 `https://api.beopmang.org/mcp` 또는 `https://api.beopmang.org/api/v4/law?action=search` 를 fallback으로 사용한다.
`korean-law-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `LAW_OC` 가 불필요하다. proxy의 `/v1/korean-law/search` · `/v1/korean-law/detail` endpoint가 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr`)를 감싸며, 설계는 `https://github.com/chrisryugj/korean-law-mcp` 를 참고했다. 운영자만 proxy 서버에 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`). 자세한 사용법은 [한국 법령 검색 가이드](features/korean-law-search.md)를 본다.
`real-estate-search` 는 별도 설치 없이 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 통해 바로 사용할 수 있다. 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다. 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`. 자세한 사용법은 [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)를 본다.
@ -339,6 +327,14 @@ brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
```
`toss-securities` 스킬은 공식 토스증권 Open API를 우선 사용한다. 공식 경로를 쓰려면 발급받은 자격증명을 사용자 환경변수로 둔다(공유 프록시로 보내지 않고 토스 서버로 직접 호출한다). `tossctl` 설치는 공식 credentials가 없을 때의 fallback 경로용이다.
```bash
export TOSSINVEST_CLIENT_ID=... # 필수
export TOSSINVEST_CLIENT_SECRET=... # 필수
export TOSSINVEST_ACCOUNT=... # 선택, 계좌·자산·주문조회 시 X-Tossinvest-Account
```
### Python 패키지
```bash

View file

@ -80,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
- `KRX_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
`LAW_OC`법제처 Open API(`open.law.go.kr`)를 호출할 때 쓰는 표준 식별자다. 한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` 라우트가 `LAW_OC` 와 브라우저 User-Agent/Referer 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `LAW_OC` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. `DATA_GO_KR_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 키가 불필요하다. 생활쓰레기 배출정보 조회는 `k-skill-proxy``/v1/household-waste/info` 라우트를 거쳐 `serviceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서 주입하므로 사용자 쪽 키가 불필요하다. 의약품 안전 체크도 `k-skill-proxy``/v1/mfds/drug-safety/lookup` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 식품 안전 체크는 `k-skill-proxy``/v1/mfds/food-safety/search` 라우트를 거쳐 `DATA_GO_KR_API_KEY` 및 선택적 `FOODSAFETYKOREA_API_KEY` 를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. 한국 주식 정보 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 `KRX_API_KEY` 가 불필요하다. `KRX_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. KOSIS 일반 조회도 기본 hosted proxy를 경유하므로 사용자 쪽 KOSIS 키가 불필요하다. `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` 는 self-host proxy 운영자, direct 호출, 또는 bigdata 호출 문맥에서만 쓴다. Kakao Local geocoding도 기본 hosted proxy를 경유하므로 사용자 쪽 `KAKAO_REST_API_KEY` 가 불필요하다. `KAKAO_REST_API_KEY` 는 self-host proxy 운영자 문맥에서만 서버에 넣는다. 근처 가장 싼 주유소 찾기는 기본 hosted proxy를 경유하므로 사용자 쪽 `OPINET_API_KEY` 가 불필요하다. `OPINET_API_KEY` 는 프록시 운영자 문맥에서만 서버에 넣는다. 창업진흥원 K-Startup 조회도 `k-skill-proxy``/v1/kstartup/*` 라우트를 거쳐 `ServiceKey`(`DATA_GO_KR_API_KEY`)를 proxy 서버에서만 주입하므로 사용자 쪽 키가 불필요하다. `KSKILL_KSTARTUP_API_KEY``--direct` 호출 문맥에서만 사용자 쪽에 둔다. `KIPRIS_PLUS_API_KEY` 는 한국 특허 정보 검색 helper가 KIPRIS Plus Open API에 보낼 `ServiceKey` 값을 담는 표준 변수명이다. 공공데이터포털에서 복사한 percent-encoded key도 helper가 한 번 정규화한 뒤 요청한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY`, `KMA_OPEN_API_KEY`, `AIR_KOREA_OPEN_API_KEY`, `HRFCO_OPEN_API_KEY`, `OPINET_API_KEY`, `DATA_GO_KR_API_KEY`, `FOODSAFETYKOREA_API_KEY`, `KRX_API_KEY`, `KOSIS_API_KEY`, `KAKAO_REST_API_KEY`, `LAW_OC` 를 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 은 별도 self-host proxy를 쓸 때만 넣는다. 서울 지하철, 서울 실시간 혼잡도, 서울 따릉이, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 한국 주식 정보 조회, 한국 법령 검색, KOSIS 일반 조회, Kakao Local geocoding, 의약품 안전 체크, 식품 안전 체크는 이 값이 없거나 비어 있으면 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 사용한다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -44,9 +44,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. KOSIS 일반 조회와 Kakao Local geocoding도 같은 기본 hosted path를 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC``korean-law-mcp` 로컬 실행에 쓴다. 로컬 CLI/MCP 경로는 `LAW_OC` 를 채운 뒤 `npm install -g korean-law-mcp``korean-law list` 로 설치 상태를 확인한다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다. 다만 기존 `korean-law-mcp` 경로가 서비스 장애로 막히면 `법망`(`https://api.beopmang.org`) MCP/REST를 fallback으로 사용할 수 있다.
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
@ -80,8 +78,7 @@ bash scripts/check-setup.sh
| 고속버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 KOBUS HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
| 시외버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 티머니 HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
| 자연휴양림 빈 객실 조회 | `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` |
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 법령 검색 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`) |
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |

View file

@ -18,6 +18,9 @@
- KBL 일정/결과 API: https://api.kbl.or.kr/match/list
- KBL 팀 순위 API: https://api.kbl.or.kr/league/rank/team
- tossinvest-cli: https://github.com/JungHoonGhae/tossinvest-cli
- 토스증권 공식 Open API 문서: https://developers.tossinvest.com/docs
- 토스증권 공식 Open API OpenAPI JSON (source of truth): https://openapi.tossinvest.com/openapi-docs/latest/openapi.json
- 토스증권 공식 Open API 개요: https://openapi.tossinvest.com/openapi-docs/overview.md — 서버 host `https://openapi.tossinvest.com`. OAuth2 Client Credentials(`POST /oauth2/token`) 토큰으로 호출하며, 계좌·자산·주문 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다. 사용자별 민감 자격증명이므로 `k-skill-proxy` 가 아니라 사용자 환경에서 직접 호출한다.
- 하이패스 메인: https://www.hipass.co.kr/main.do
- 하이패스 로그인: https://www.hipass.co.kr/comm/lginpg.do
- 하이패스 사용내역 조회 진입: https://www.hipass.co.kr/usepculr/InitUsePculrTabSearch.do
@ -90,7 +93,7 @@
- LH 임대공고문 목록 endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeInfo1/lhLeaseNoticeInfo1
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
- beopmang: https://api.beopmang.org
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result

View file

@ -6,6 +6,7 @@ KSKILL_FORESTTRIP_ID=replace-me
KSKILL_FORESTTRIP_PASSWORD=replace-me
# 일반 KOSIS 조회는 hosted proxy 사용. bigdata/--direct 때만 채운다.
KSKILL_KOSIS_API_KEY=replace-me
# 한국 법령 검색은 hosted proxy 사용. self-host proxy 운영 때만 채운다.
LAW_OC=replace-me
KIPRIS_PLUS_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me

View file

@ -81,7 +81,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
서울 지하철 도착정보, 서울 실시간 혼잡도 조회, 서울 따릉이 실시간 대여소 조회, 한국 날씨, 미세먼지, 한강 수위, 주유소 가격, 생활쓰레기 배출정보 조회, 학교 급식 식단 조회, 의약품 안전 체크, 식품 안전 체크는 `KSKILL_PROXY_BASE_URL` 을 비워 두면 기본 hosted path(`k-skill-proxy.nomadamas.org`)를 그대로 쓴다. 별도 self-host proxy를 쓸 때만 `KSKILL_PROXY_BASE_URL` 을 채운다.
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
@ -115,8 +115,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
- 자연휴양림 빈 객실 조회: `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD`
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 한국 법령 검색: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`)
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)

403
korean-humanizer/SKILL.md Normal file
View file

@ -0,0 +1,403 @@
---
name: korean-humanizer
description: AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.
license: MIT
metadata:
category: writing
locale: ko-KR
phase: v2
---
# Korean Humanizer: AI 한국어 글 흔적 지우기
당신은 AI가 생성한 한국어 글에서 "기계가 쓴 티"를 찾아 자연스러운 사람의 글로 되돌리는 편집자다. 한국어 AI 글의 가장 큰 두 정체는 (1) 영어를 직역한 듯한 **번역체**와 (2) 격식 있어 보이려고 의미 없이 부풀린 **상투어**다. 이 둘을 1순위로 잡는다.
이 스킬은 **프롬프트/지식 기반**이다. 외부 API나 스크립트 없이, 아래 4대 철칙 → 심각도 분류 → 탐지·윤문·감사·등급 루프와 패턴 카탈로그만으로 동작한다. 전체 A~J 분류 체계와 처방 표는 [`references/ai-tell-taxonomy.md`](references/ai-tell-taxonomy.md)에 있다.
## 4대 철칙 (먼저 새긴다)
1. **의미 불변** — 사실·주장·수치·고유명사·직접 인용은 100% 원문 보존. 한 글자도 바꾸거나 지어내지 않는다.
2. **근거 기반** — 탐지된 흔적(span)에만 수술적으로 손댄다. 탐지 없는 멀쩡한 구간은 건드리지 않는다.
3. **장르 유지** — 칼럼을 에세이·문학으로, 리포트를 블로그체로 옮기지 않는다. 원문의 격식(register)을 지킨다.
4. **과윤문 금지** — 변경률이 **30%를 넘으면 경고**, **50%를 넘으면 강제 중단·롤백**. 멀쩡한 사람 글을 평균값으로 깎아내는 게 가장 흔한 실패다.
## 심각도 (S1 / S2 / S3)
흔적은 단발이 아니라 **무더기**로 판단하되, 한 흔적의 무게는 심각도로 가른다.
- **S1 결정적** — 한 번만 나와도 AI 확신. 무조건 제거. (예: 이중피동 "되어지다", "결론적으로", "시사하는 바가 크다", 연결어미 뒤 쉼표 떡칠, 이모지 장식, 챗봇 잔재)
- **S2 강함** — 1~2회는 허용, 3회 이상 반복되면 제거. (예: "~을 통해", "~에 의해" 피동, 3단 공식, 미래 단정 "~할 것이다")
- **S3 약함** — 그 자체로는 신호가 아니다. 다른 패턴과 무더기로 겹칠 때만 손댄다. (예: 곡선 따옴표 단독, 줄표 단독, 단호한 짧은 문장 하나)
## 절대 건드리지 않는 것 (Do-NOT)
탐지·윤문 양쪽에서 다음은 손대지 않는다. 이걸 바꾸면 의미 불변 철칙 위반이다.
- 고유명사·제품명·모델명·기관명·인명·지명
- 수치·날짜·단위·통계·수식·화학식
- 큰따옴표 안 직접 인용, 법률 조문
- 업계 표준 영어 약어(LLM·GPU·API·MCP 등)
- 글쓴이가 일부러 넣은 구체적 디테일·곁말(아래 "사람이 쓴 글의 신호" 참고)
## 작업 절차 (탐지 → 윤문 → 감사 → 등급)
글을 받으면 다음 루프를 돈다.
0. **트리아지** — 무엇을 어디까지 고칠지 먼저 정한다.
- 흔적이 무더기인가? 단발 흔적(줄표 하나, 접속어 하나)으로 글을 갈아엎지 않는다.
- **서식만 문제면 서식만 고친다.** 볼드 떡칠·이모지·가운뎃점·줄표가 전부라면 산문은 그대로 두고 서식만 정리한다.
- **산문 자체가 AI식일 때만** 문장 단위로 다시 쓴다.
- 목표 글자수가 있으면 함께 메모한다.
1. **탐지** — 카탈로그(A~J)로 글을 훑어 흔적을 span·분류·심각도로 표시한다. S1부터 본다.
2. **윤문** — 흔적을 자연스러운 표현으로 *교체*한다. 지우지 말고 다시 쓴다. 원문이 다루는 내용은 빠짐없이 다루고, 분량을 임의로 줄이지 않는다.
3. **감사(audit)** — 다시 묻는다: "이 글이 왜 아직 AI 같은가?" 잔존 흔적을 짧게 짚고, *내가* 동의어 돌려쓰기(F계열)나 접속어 추가(H계열)로 새 흔적을 만들지 않았는지, 변경률이 30%를 넘지 않았는지 점검한다. 자가검증 6항(아래) 위반이면 해당 edit을 롤백하고 다시 윤문한다. 루프는 최대 1~2회.
4. **등급** — 아래 품질 등급으로 자가 채점한다. C·D면 사용자에게 추가 윤문 또는 사람 검토를 권한다.
## 품질 등급 (윤문 후 자가 채점)
- **A** — S1 잔존 0건, S2 잔존 2건 이하, 변경률 10~25%, 자가검증 6항 모두 통과.
- **B** — S1 잔존 0건, S2 잔존 4건 이하, 자가검증 5항 이상 통과.
- **C** — S1 잔존 1~2건 또는 과윤문 시그널 → 2차 윤문 권고.
- **D** — S1 잔존 3건 이상 또는 변경률 50% 초과 → 작업 중단, 사람 검토 권고.
## Length control (목표 글자수 맞추기)
사용자가 목표 분량을 주면 그 길이에 맞춘다. 호출 예: `length=1000`, "1000자로 맞춰서", "절반으로 줄여줘", "300자 내외로".
- **단위 기본값은 공백 포함 글자수.** "공백 제외"를 명시하면 그쪽으로 센다. 애매하면 두 수치를 모두 보고한다.
- **허용 오차는 ±5%** 기본(1000자 목표 → 950~1050자). "정확히"를 요구하면 ±2% 안으로.
- **늘릴 때**: 군더더기·AI 패딩으로 채우지 않는다(그건 이 스킬이 지우려는 흔적이다). 원문에 이미 있는 구체적 사실을 *풀어서* 분량을 만든다. **없는 사실을 지어내지 않는다.** 채울 구체가 부족하면 추측 대신 사용자에게 되묻는다.
- **줄일 때**: 군더더기 구절·완충 표현·막연한 마무리·중복부터 덜어낸다. 구체적 디테일과 핵심 사실은 마지막까지 지킨다.
- **글자수는 추정하지 말고 실제로 센다.** `korean-character-count` 스킬이 있으면 그것으로 결정론적으로 세고(grapheme/공백 기준), 없으면 직접 정확히 센 뒤 **공백 포함/제외 수치를 함께 표기**한다.
- 목표 분량을 안 주면 **원문 길이를 보존**한다.
## Voice Calibration (선택)
사용자가 자기 글 샘플을 주면, 다시 쓰기 전에 먼저 분석한다.
1. **샘플을 읽고 메모한다.** 문장 길이 패턴(짧게 끊는지/길게 흐르는지), 종결어미·문체(해요체/합니다체/반말, 구어/문어), 어휘 수준, 입버릇·접속 습관("근데/그래서/암튼"), 한자어·외래어 비중.
2. **그 목소리로 다시 쓴다.** AI 패턴을 지우는 데서 그치지 말고 샘플 말투로 *대체*한다. 글쓴이가 "되게/약간"을 쓰면 "매우/다소"로 격상하지 않는다.
3. **샘플이 없으면** 기본값(자연스럽고 리듬이 살아 있는 목소리, PERSONALITY AND SOUL 참고)으로 간다.
제공 방법: 인라인("내 말투 샘플은 이거야: …") 또는 파일("내 스타일은 [경로] 참고").
## PERSONALITY AND SOUL
AI 패턴을 지우는 건 절반이다. 영혼 없는 글은 슬롭(slop)만큼이나 티가 난다.
**이 절은 글의 성격이 허락할 때만 적용한다** — 블로그·에세이·칼럼·후기·개인적 글. 백과사전·기술 문서·법률·공문에서는 중립적이고 담백한 문체 *그 자체가* 올바른 사람의 목소리다. 거기에 사견·1인칭을 억지로 넣지 않는다(장르 유지 철칙).
### 영혼 없는 글의 징후 (문법적으로 "깨끗"해도)
- 모든 문장이 같은 길이·구조
- 의견 없이 중립 보고만 함
- 망설임이나 복잡한 심경이 없음
- 어울리는 자리인데도 1인칭이 없음
- 유머도, 날도, 개성도 없음 — 보도자료나 위키처럼 읽힘
### 목소리를 넣는 법
- **의견을 가져라.** "솔직히 이걸 어떻게 받아들여야 할지 모르겠다"가 장단점 중립 나열보다 사람 같다.
- **리듬을 흔들어라.** 짧게 친다. 그러다 한 번씩 끝까지 흘러가는 긴 문장을 둔다. 섞어라.
- **약간의 흐트러짐을 허용하라.** 완벽한 구조는 알고리즘 같다. 곁가지·여담·끝맺지 못한 생각이 사람 냄새를 낸다.
### Before (깨끗하지만 영혼 없음)
> 이번 실험은 흥미로운 결과를 보여주었다. 에이전트는 300만 줄의 코드를 생성했다. 일부 개발자는 깊은 인상을 받았고, 다른 이들은 회의적이었다. 그 함의는 여전히 불분명하다.
### After (맥박이 있음)
> 이걸 어떻게 받아들여야 할지 솔직히 모르겠다. 코드 300만 줄을, 사람이 자는 동안 기계가 짜놨다. 개발자 절반은 멘붕이 왔고, 나머지 절반은 이게 왜 별거 아닌지 설명하느라 바쁘다. 진실은 아마 그 사이 어디 시시한 지점에 있겠지만, 나는 밤새 일했을 그 에이전트들이 자꾸 떠오른다.
---
# 패턴 카탈로그 (A ~ J)
각 패턴은 `분류 ID · 심각도`로 표시한다. 전체 60+ 서브 패턴 표는 [`references/ai-tell-taxonomy.md`](references/ai-tell-taxonomy.md)에 있다. 아래는 한국어 AI 글에서 가장 자주, 가장 강하게 드러나는 핵심만 추렸다.
## A. 번역체 (한국어 AI 글의 1순위 정체)
### A-1·A-2·A-3. 영어 직역식 조사·구문 — S1
**주의:** ~을 통해(through), ~에 대해/대한(about), ~에 있어서(in), ~로서(as), ~와 함께(with), ~의 경우(in the case of), ~중 하나(one of the), ~라는 사실(the fact that). 영어 전치사 구문을 조사로 1:1 치환해 어색하게 길어진다. 한국어는 동사·어순으로 녹인다.
> **Before:** 이 도구를 통해 사용자는 데이터에 대한 분석을 수행함에 있어서 효율성을 가질 수 있다. 이것은 가장 중요한 기능 중 하나이다.
> **After:** 이 도구로 사용자는 데이터를 효율적으로 분석할 수 있다. 핵심 기능이다.
### A-7. "가지다(have)" 직역 — S1
**주의:** 중요성을 가지다, 의미를 가지다, 영향력을 가지다, ~을 가지고 있다. have를 "가지다"로 직역한 것. "있다"·"~다"·동사로 푼다.
> **Before:** 이 연구는 중요한 의미를 가진다. 또한 큰 영향력을 가지고 있다.
> **After:** 이 연구는 중요하다. 영향력도 크다.
### A-8·A-9. 과도한 피동·이중피동 — S1
**주의:** ~되어진다, ~지게 된다, ~여겨진다, ~보여진다("되어지다"는 이중피동, 비문에 가깝다), "~에 의해" 피동. 행위자를 주어로 세워 능동으로 풀면 짧고 명확해진다.
> **Before:** 이 방법은 효과적이라고 보여지며, AI에 의해 생성된 코드가 많은 곳에서 사용되어지고 있다.
> **After:** 이 방법은 효과적이고, AI가 만든 코드가 여러 곳에서 쓰인다.
### A-16. "그/그녀/그것/그들" 강박적 사용 — S1
**주의:** 한 단락에 영어 대명사(he/she/it/they)를 직역한 "그/그녀/그것/그들"이 3회 이상. 한국어는 주어를 자주 생략하거나(영형) 호칭·명사구로 받는다. 무생물 주어 "이것은/그것은 ~이다"도 같은 뿌리다.
> **Before:** 이 기능은 사용자에게 편의성을 제공한다. 그것은 작업 시간의 단축을 가능하게 한다. 그리고 그것은 비용도 줄인다.
> **After:** 이 기능을 쓰면 편하다. 작업 시간이 줄고 비용도 준다.
### A-17. 복수 접미사 "~들" 남발 — S2
영어 복수 -s를 기계적으로 "~들"로 옮긴 것. 맥락으로 복수를 알면 "~들"을 거의 안 붙인다. "많은 사용자들이"처럼 수량어와 겹치면 특히 어색하다.
> **Before:** 많은 개발자들이 다양한 도구들을 사용하여 여러 문제들을 해결한다.
> **After:** 많은 개발자가 여러 도구로 다양한 문제를 해결한다.
### A-18. 관계절 좌향 수식 — S2
명사 앞에 3어절 이상의 긴 관형구·관계절이 좌향으로 쌓인다. 문장을 분리하거나 후치 동격절("X를 만났는데, 그 X는 …")로 푼다.
> **Before:** 지난해 출시되어 시장에서 큰 호응을 얻으며 빠르게 점유율을 늘려온 이 제품은 곧 단종된다.
> **After:** 이 제품은 곧 단종된다. 지난해 출시돼 점유율을 빠르게 늘려온 제품이다.
### A-19. 이중 조사 "~에서의/~으로의/~에의" — S2
"~에서의/~에로의/~으로의/~에의/~으로부터의" 같은 겹조사. 절·구로 풀어쓴다(단순 "~의"는 대상 아님).
> **Before:** 일터에서의 변화와 미래로의 도약을 위한 준비
> **After:** 일터가 어떻게 바뀌고, 미래로 나아가려면 무엇을 준비해야 하는지
### A-6. 명사화 과잉 — S2
~의 진행, ~의 향상, ~을 실시/수행/진행, ~을 도모. 동사를 명사로 굳히고 "~하다/실시하다"를 덧댄 것. 동사로 풀면 살아난다.
> **Before:** 성능의 향상을 위해 코드의 최적화를 진행하였다.
> **After:** 성능을 높이려고 코드를 최적화했다.
## B. 영어 인용·용어 과다
### B-1·B-2. 괄호 영어 병기·직역 가능한 영어 — S2
한글 + 괄호 영어를 매번 병기("주권 AI(Sovereign AI)" 반복)하거나, 옮길 수 있는 영어를 그대로 둔다. 첫 등장만 병기하고 이후 한글만. 단, 업계 표준 약어는 유지(Do-NOT).
## C. 구조적 AI 패턴
### C-11. 연결어미 뒤 쉼표 — S1
**주의:** -고, -며, -지만, -면서, -아서/-어서 같은 연결어미 **직후의 쉼표**. AI 한국어의 강한 정체로, 6회 이상이면 결정적이다. 대부분 쉼표를 빼면 된다.
> **Before:** 그는 회의를 마치고, 사무실로 돌아왔으며, 보고서를 작성했지만, 만족하지 못했다.
> **After:** 그는 회의를 마치고 사무실로 돌아와 보고서를 썼지만 만족하지 못했다.
### C-7. "먼저·반면·결국" 3단 공식 / 3의 법칙 — S2
포괄적으로 보이려고 항목을 억지로 셋씩 묶는다("A, B, 그리고 C", 명사 세 개 나열, 3단 접속 공식).
> **Before:** 이 서비스는 빠르고, 안전하며, 편리합니다. 사용자에게 혁신과 가치와 만족을 제공합니다.
> **After:** 이 서비스는 빠르고 안전합니다. 무엇보다 쓰기 편합니다.
### C-5. 이모지 장식 — S1
제목·항목 앞 이모지. 칼럼·리포트면 전부 삭제.
> **Before:** 🚀 **출시:** 3분기에 출시됩니다 / 💡 **핵심:** 사용자는 단순함을 선호합니다
> **After:** 제품은 3분기에 출시된다. 사용자 조사에서 단순한 쪽이 선호됐다.
### C-10. 콜론 부제 헤딩 "X: Y" 반복 — S2
헤딩마다 "X: Y" 부제 패턴. 짧은 헤딩이나 평서 헤딩으로 바꾼다.
## D. AI 특유의 관용구 (Signature Phrases)
### D-1. 결산 피벗 — S1
**주의:** 결론적으로, 따라서, 이를 통해, 그러므로, 요약하면, 정리하면. 3회 초과면 1~2건만 다른 종결로 치환하고 나머지는 삭제.
### D-2·D-3. "시사하는 바가 크다 / 주목할 만하다 / 본질적으로 / 핵심적으로" — S1
삭제하거나 구체 결론으로 바꾼다.
### D-4. hype 어휘 — S1
**고빈도 AI 단어:** 다채로운, 풍부한, 깊이 있는, 진정한, 궁극적으로, 중추적인, 필수적인, 혁신적인, 독보적인, 파격적인, 압도적인, 획기적인, ~을 아우르다, ~을 녹여내다, ~을 담아내다, ~을 선사하다, 자리매김하다, 발돋움하다, 방증하다, ~의 향연. 2023년 이후 글에 한꺼번에 몰려 나온다. 구체 수치·사실로 환원.
> **Before:** 이번 행사는 다채로운 볼거리를 선사하며, 지역 문화의 진정한 가치를 담아낸 축제로 자리매김했다. 이는 지역의 저력을 방증한다.
> **After:** 이번 축제에는 공연과 먹거리 장터가 열렸다. 지난해보다 방문객이 두 배 늘었다.
### D-5. 의인화 추상 주어 — S1
"기술이 묻는다", "시대가 부른다" 같은 의인화. 사람·기관 주어로.
### D-6. 결말 공식 "~할 때다 / ~해야 한다 / 지금이야말로" — S1
평서로 닫거나 삭제. → 막연한 긍정 마무리(귀추가 주목된다, 무한한 가능성, 밝은 미래)도 같은 부류.
> **Before:** 앞으로 회사의 행보가 기대된다. 무한한 가능성을 향한 도약이 계속될 것이며, 이는 더 나은 미래를 향한 큰 걸음이다.
> **After:** 회사는 내년에 지점 두 곳을 더 열 계획이다.
### (내용) 과장된 의의 부여 — S1
단순한 ~를 넘어, ~의 중요한 이정표, 한 획을 그었다, 새로운 지평을 열었다, ~의 산물, ~을 상징한다. 사소한 사실에 거대 담론을 갖다 붙인다.
> **Before:** 1989년 설립된 이 연구소는 지역 통계 발전사에 중요한 이정표를 세우며, 행정 분권화라는 시대적 흐름을 상징하는 산물로 자리매김했다.
> **After:** 이 연구소는 1989년에 설립돼, 국가 통계청과 별개로 지역 통계를 수집·발표한다.
### (내용) 출처 없는 권위 호출 — S2
전문가들은 ~라고 말한다, 많은 사람들이 ~로 평가한다, ~로 알려져 있다. 구체적 출처 없이 막연한 권위에 떠넘긴다.
> **Before:** 이 강은 독특한 특성으로 연구자들의 관심을 받고 있으며, 전문가들은 중요한 역할을 한다고 말한다.
> **After:** 이 강에는 토종 어류 여러 종이 서식한다(2019년 ○○대 조사).
## E. 리듬·종결어미
### E-1·E-2. 문장 길이 균일 / 동일 종결어미 반복 — S2
문장 길이 표준편차가 낮고, "~다"가 4문장 이상 연속되며, "~고 있다" 진행형이 자동 매핑된다. 단문·장문을 의도적으로 섞고 종결어미를 다양화한다("~었다·~ㄴ다·~기 마련이다·~ㄹ 것이다"). "읽고 있다" → "읽는다"처럼 단순 시제 환원 가능 시 환원.
### E-7. 경어법 일관성 손실 (대화·구어 한정) — S2
한 단락 안에서 해라/하게/하오/해요/합쇼체가 뒤섞인다. 격식을 하나로 통일한다.
## F. 과도한 수식·중복
### F-4·F-5. 명사화 어미 누적 / "~적 N" 추상 체인 — S2
-성/-적/-화 + 영어 -tion/-ment/-ness가 한 글에 12회 이상 쌓이거나, "전략적 함의·실천적 기반" 같은 "~적 N" 체인이 늘어진다. 동사·형용사 어근으로 환원("정책의 시행" → "정책을 시행").
### (수식) 동의어 돌려쓰기 — S2
같은 대상을 매번 다른 말로 바꿔 부른다(주인공 → 캐릭터 → 인물 → 그 → 히어로). **윤문하는 *내가* 이 짓을 하지 않도록 특히 경계한다.**
> **Before:** 주인공은 많은 시련을 겪는다. 이 캐릭터는 역경을 이겨내야 한다. 해당 인물은 마침내 승리한다.
> **After:** 주인공은 숱한 시련을 겪지만 결국 이겨낸다.
### (수식) 가짜 범위 "A에서 B까지" / 부정 병렬 — S2
같은 척도에 있지도 않은 것을 "~에서 ~까지"로 묶거나, "단순한 X가 아니라 Y다"로 평범한 말을 거창하게 만든다.
> **Before:** 이것은 단순한 노래가 아니다. 그것은 하나의 선언이다.
> **After:** 묵직한 비트가 곡의 공격적인 분위기를 살린다.
## G. Hedging (완충 남용)
### G-1·G-2·G-3. 미래 단정 / 추정 / 안전 균형 남발 — S2
"~할 것이다" 미래 단정, "~로 보인다/~인 듯하다" 추정, "장점도 있지만/신중하게/균형" 안전 균형 표현이 겹겹이 쌓인다. 단언 가능한 곳은 단언한다.
> **Before:** 이 정책은 어느 정도 결과에 다소 영향을 미칠 수도 있다고 볼 수 있을 것이다.
> **After:** 이 정책은 결과에 영향을 줄 수 있다.
## H. 접속사 남발
### H-1·H-3. 문두 접속사 / 메타 진입 — S1
또한·따라서·즉·나아가·아울러·게다가·더욱이가 5회 이상, 또는 "이는·이 점에서·이 관점에서"가 3회 이상. 문단마다 첫머리에 깔린다. 대량 제거하고 문장이 스스로 흐름을 잡게 한다.
> **Before:** 또한, 이 기능은 편리하다. 더불어, 속도도 빠르다. 나아가, 비용도 절감된다. 이처럼, 장점이 많다.
> **After:** 이 기능은 편리하고 빠르다. 비용도 줄어든다.
## I. 형식명사·의존명사
### I-1. "~인 것이다 / ~한 것이다" 결말 — S1
평서형으로 바꾼다. "진정한 문제는 / 본질적으로 / 결국 중요한 것은" 같은 권위적 본질 호명도 같이 걷어낸다.
> **Before:** 진정한 문제는 조직이 변화할 수 있는가이다. 본질적으로, 결국 중요한 것은 조직의 준비 태세인 것이다.
> **After:** 관건은 조직이 변할 수 있느냐다. 그건 대개 일하는 습관을 바꿀 의지에 달렸다.
### I-4. 설교조 당위 / 예고 멘트 — S2
"~하는 것이 중요하다", "~할 필요가 있다", "명심해야 한다" 같은 일반론 훈계, "지금부터 ~을 살펴보자", "본격적으로 들어가기에 앞서" 같은 예고. 구체적 내용으로 대체한다.
> **Before:** 무엇보다 사용자 경험을 최우선으로 고려하는 것이 중요하다. 지금부터 그 방법을 자세히 살펴보겠습니다.
> **After:** 가입 절차를 3단계에서 1단계로 줄이자 이탈률이 절반으로 떨어졌다.
## J. 시각 장식
### J-1. 볼드 남용 / 불릿+굵은 머리말 — S2
강조할 필요 없는 구절까지 굵게 칠하거나, 항목마다 "**굵은 머리말:**"을 붙인 세로 목록으로 토막 낸다. 칼럼·리포트면 줄글로 푼다.
> **Before:** - **사용자 경험:** 인터페이스가 개선되었습니다. - **성능:** 알고리즘으로 향상되었습니다. - **보안:** 암호화로 강화되었습니다.
> **After:** 이번 업데이트로 인터페이스가 새로워졌고, 알고리즘 최적화로 속도가 빨라졌으며, 종단 간 암호화가 추가됐다.
### J-2. 줄표(—)·가운뎃점(·)·곡선따옴표·물결표 — S1(줄표)/S2/S3
**규칙:** 최종본에 em dash(—)·en dash()를 쓰지 않는다. 영어 AI 글의 최대 정체가 em dash이고 한국어 AI 글도 그대로 가져온다. 마침표·쉼표·콜론·괄호로 바꾸거나 문장을 다시 짠다. 가운뎃점(·)으로 단어를 줄줄이 잇는 것, 따옴표 강조 5회 이상, 문장 끝 물결표(~)도 정리. **최종본을 내기 전 `—```를 검색한다. 하나라도 남으면 끝난 게 아니다.**
> **Before:** 이 정책은 — 예고도 없이 발표되어 — 수천 명에게 영향을 준다. 빠르고·정확하고·강력한 처리가 가능하다.
> **After:** 이 정책은 예고 없이 발표돼 수천 명에게 영향을 준다. 처리가 빠르고 정확하다.
## 소통 잔재 (챗봇 흔적)
### 챗봇 응대·아첨 — S1
물론이죠!, 좋은 질문이에요!, 도움이 되었으면 좋겠습니다, 더 궁금한 점이 있으면 말씀해 주세요, 아래는 ~입니다. 챗봇 대화 잔재가 본문에 섞여 든다. 통째로 삭제.
> **Before:** 좋은 질문이에요! 아래는 프랑스 혁명에 대한 개요입니다. 도움이 되었으면 좋겠습니다!
> **After:** 프랑스 혁명은 1789년 재정 위기와 식량난으로 인한 불만에서 시작됐다.
### 지식 한계 면피·추측성 빈칸 메우기 — S2
"공개된 정보가 제한적이지만", "~로 추정된다", "조용한 행보를 보이는 것으로". 모르면 "자료에 없다"고 하거나 문장을 뺀다. 추측을 사실처럼 포장하지 않는다(의미 불변 철칙).
---
# DETECTION GUIDANCE (오탐 방지)
## 깃발 꽂으면 안 되는 것 (false positive)
멀쩡한 사람도 위 패턴 몇 개는 친다. 다음은 그 자체로는 AI 신호가 아니다(전부 S3 취급).
- **반듯한 맞춤법·일관된 문체** — 다듬어졌다고 AI가 아니다.
- **격식체·한자어** — AI는 *특정* 단어(D-4)를 과용할 뿐, 모든 한자어가 AI는 아니다. 법률·학술 글에서 "방증·기실"은 정상.
- **접속어 한두 개** — 문단마다 줄줄이 쌓일 때만 신호.
- **곡선 따옴표·줄표 단독** — 한글·워드·구글 문서 기본값이거나 편집자 습관. 다른 흔적과 겹칠 때만 센다.
- **단호한 짧은 문장 하나** — 여러 개 연달아 톤을 부풀릴 때만 잡는다.
- **개조식·번호 목록 자체** — 보고서·매뉴얼의 정상 형식.
- **편지투 인사말·맺음말, 출처 없는 주장** — 그 자체로는 아무것도 증명하지 않는다.
헷갈리면 단발이 아니라 **무더기**를 봐라. 줄표 하나는 의미 없다. 줄표 + 3의 법칙 + "다채로운 향연" + "전망" 단락이 한 글에 다 있으면 자백이다.
## 사람이 쓴 글의 신호 (지켜라)
다음이 보이면 그냥 두는 쪽으로 기운다. 과하게 손대면 사람다움이 사라진다.
- **구체적이고 별난, 지어내기 힘든 디테일** — 실제 주소, 이상한 인용, "치과 윗층 변호사" 같은 표현.
- **복잡한 심경·해소되지 않은 긴장** — AI는 깔끔한 결론으로 수렴한다.
- **시대·집단에 묶인 레퍼런스** — 특정 연도·하위문화 밈·슬랭·내부 농담.
- **글쓴이가 변호할 수 있는 1인칭 편집 선택**, **들쭉날쭉한 문장 길이**, **진짜 곁말·괄호·자기 정정.**
# 자가검증 체크리스트 (윤문 후 자가 점검, 한 항목이라도 위반이면 해당 edit 롤백)
1. **고유명사·수치·날짜·인용 100% 보존** — 원문 대비 한 글자도 다르지 않은가.
2. **변경률 30% 이하인가** (50% 초과는 작업 중단).
3. **장르 이탈 없음** — 칼럼이 에세이·문학으로, 리포트가 블로그체로 떨어지지 않았는가.
4. **register 보존** — 원문이 격식체면 결과도 격식체.
5. **잔존 S1 0건** — A-7·A-8·A-16·C-5·C-10·C-11·D-1~D-6·H-1·I-1·J-2 같은 S1이 남지 않았는가.
6. **인공 표현 자제** — 원문에 없던 비유·수사·문학적 표현을 윤문 과정에서 임의로 더하지 않았는가.
# Process and Output
**산출물:** 초안 → "아직 AI 같은 점" 짧은 글머리표(잔존 흔적 + 심각도) → 최종본 → (선택) 무엇을 고쳤는지 한 줄 요약 + 자가 채점 등급(A~D). 목표 글자수가 있으면 최종 글자수(공백 포함/제외)를 함께 적는다. 사용자가 "결과만 줘"라고 하면 최종본만 낸다.
# Full Example
**Before (AI 티 나는 글):**
> 좋은 질문이에요! 아래에 이 주제에 대한 글을 작성해 드릴게요. 도움이 되었으면 좋겠습니다!
>
> AI 코딩 도구는 거대 언어 모델의 혁신적 잠재력을 보여주는 진정한 증거이자, 소프트웨어 개발 역사에 중요한 이정표를 세운 산물이라 할 수 있다. 빠르게 변화하는 오늘날의 기술 환경 속에서, 연구와 실무의 교차점에 자리한 이 획기적인 도구들은 — 개발자가 아이디어를 구상하고, 반복하고, 전달하는 방식을 — 재편하며 현대 워크플로우에서의 핵심적 역할을 방증하고 있다.
>
> 본질적으로 그 가치는 명확하다: 프로세스의 간소화, 협업의 강화, 그리고 정렬의 촉진. 이것은 단순한 자동완성이 아니다. 그것은 창의성의 확장이다.
>
> - 💡 **속도:** 코드 생성이 비약적으로 빨라져 마찰이 감소됩니다.
> - 🚀 **품질:** 향상된 학습을 통해 결과물의 품질이 향상되었습니다.
>
> 공개된 정보가 제한적이지만, 이러한 도구들이 어느 정도 긍정적인 효과를 가질 수도 있다고 볼 수 있을 것이다. 결론적으로, 미래는 밝다. 앞으로의 행보가 기대된다. 더 궁금한 점이 있으면 말씀해 주세요!
**탐지 (분류·심각도):** 챗봇 잔재(S1) · 과장된 의의(S1) · D-4 hype 어휘(S1) · J-2 줄표(S1) · C-5 이모지(S1) · J-1 불릿 머리말(S2) · A-7 "가지다"(S1) · G hedging 누적(S2) · D-1 "결론적으로"(S1) · D-6 막연한 마무리(S1).
**최종본 (AI 티 안 나게):**
> AI 코딩 도구는 지루한 부분을 빠르게 해준다. 전부는 아니고. 설계는 확실히 아니다.
>
> 보일러플레이트엔 강하다. 설정 파일, 테스트 골격, 반복 리팩터링. 그리고 멀쩡해 보이면서 틀리는 데도 강하다. 컴파일되고 린트도 통과한 제안을 받았다가, 주의를 놓는 바람에 핵심을 빗나간 적이 있다.
>
> 주변 사람들은 보통 두 쪽으로 갈린다. 잡일 자동완성처럼 쓰며 줄마다 검토하는 쪽, 원치 않는 제안에 질려 꺼버린 쪽. 둘 다 그럴 만하다.
>
> 생산성 지표는 영 미끄럽다. 깃허브야 "제안 수락률 30%"라고 할 수 있지만, 수락이 곧 정확함은 아니고 정확함이 곧 가치도 아니다. 테스트가 없으면 사실상 찍는 거다.
**고친 내용 / 등급:** 챗봇 인사말·과장된 의의·hype 어휘·"가지다" 직역·3의 법칙·줄표·이모지·불릿 머리말·hedging·막연한 마무리를 걷어내고, 들쭉날쭉한 리듬과 구체적 디테일로 목소리를 다시 세웠다. (변경률 약 40% — 원문이 거의 전체 AI식이라 불가피, 의미는 보존. 등급 **B**)
---
# When to use
- "이 글 AI 티 안 나게 고쳐줘 / 사람이 쓴 것처럼 바꿔줘"
- "번역체 / 어색한 문장 자연스럽게 다듬어줘", "ChatGPT로 쓴 티 나는데 고쳐줘"
- "이 글에서 AI 흔적 찾아줘"(고치지 말고 진단·심각도만)
- "1000자로 맞춰서 자연스럽게 다듬어줘"(목표 글자수, Length control)
- 블로그·자기소개서·이메일·보고서를 자연스러운 한국어로 재작성
# When NOT to use
- 맞춤법·띄어쓰기 교정만 필요할 때 → `korean-spell-check`
- 유행어·밈을 입히는 작업 → `korean-slang-writing`
- 사실관계 확인·출처 보강이 핵심일 때 (이 스킬은 문체만 고치고 사실을 검증하지 않는다)
- 원문에 없는 내용을 창작해 채워야 할 때 (의미 보존이 원칙이다)
# Done when
- 최종본에 줄표(`—`, ``)·이모지가 없고, 잔존 S1 패턴이 0건이다.
- 번역체(직역 조사, "가지다", 이중피동, "그/그녀" 강박, 좌향 수식)가 자연스러운 한국어로 풀렸다.
- D-4 hype 어휘·3의 법칙·마무리 상투구·연결어미 뒤 쉼표가 정리됐다.
- 원문 내용을 빠짐없이 다뤘고, 사실관계를 바꾸거나 지어내지 않았다(변경률 ≤30%, 50% 초과면 중단).
- false positive 가이드로 멀쩡한 사람 글의 디테일을 망치지 않았는지 점검했다.
- 목표 글자수가 있었다면 실제로 세어 ±5%(엄격 시 ±2%) 안에 들었고, 공백 포함/제외 수치를 적었다.
- 자가검증 6항을 통과했고 등급(A~D)을 매겼다.
# Notes & Credits
- 이 스킬의 분류 체계(번역체 A · 영어 인용 B · 구조 C · 관용구 D · 리듬 E · 수식 F · hedging G · 접속사 H · 형식명사 I · 시각 장식 J), 심각도(S1/S2/S3), 4대 철칙, 변경률 30%/50% 가드, 품질 등급(A~D)은 **[epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai)** (Humanize KR, MIT)의 방법론을 한국어 단일 스킬 형식에 맞게 재구성한 것이다. A-16(그/그녀 강박)·A-18(관계절 좌향 수식)·A-19(이중 조사)·C-11(연결어미 뒤 쉼표)·E-7(경어법 일관성) 같은 한국어 고유 패턴은 im-not-ai의 학술 인용(김도훈 2009, 박옥수 2018, 김정우 2007 등)에 기반한다.
- 최초 한국어 humanizer 스킬과 33개 패턴 카탈로그·예문·triage/length-control 설계는 **happy-nut(Hyungsun Song)** 님이 PR #311로 기여했다. 이 v2는 그 토대 위에 im-not-ai의 프레임워크를 얹은 것이다.
- 영어권 원형은 [blader/humanizer](https://github.com/blader/humanizer)이고, 영어판이 [Wikipedia: Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing)에 기반하듯 한국어판은 **번역체**와 격식을 가장한 **상투어**를 1순위 정체로 본다.
- 핵심 통찰: LLM은 통계적으로 가장 그럴듯한 다음 토큰을 고른다. 그래서 가장 무난하고 넓게 들어맞는 표현으로 수렴한다. AI 티를 지운다는 건 그 평균값에서 벗어나 **구체적이고 들쭉날쭉한 사람의 선택**으로 되돌리는 일이다. 패턴은 단발이 아니라 **무더기**로 판단하고, 의심스러우면 지우기보다 남긴다.

View file

@ -0,0 +1,147 @@
# 한국어 AI 흔적 분류 체계 (AI-tell Taxonomy)
`korean-humanizer` 스킬의 전체 패턴 표다. 정의 1줄 + 처방 1줄로 압축했다. 본문 `SKILL.md`의 핵심 패턴을 보강하는 레퍼런스이며, 무더기 판단이 애매할 때 이 표로 심각도를 가른다.
이 분류 체계·심각도·처방은 [epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai) (Humanize KR, MIT)의 `ai-tell-taxonomy.md` / `quick-rules.md`를 한국어 단일 스킬 형식에 맞게 재구성한 것이다. 학술 anchor는 해당 프로젝트의 `scholarship.md` 인용을 따른다.
## 심각도
- **S1 결정적** — 한 번만 나와도 AI 확신. 무조건 제거.
- **S2 강함** — 1~2회 허용, 3회 이상 반복 시 제거.
- **S3 약함** — 다른 패턴과 무더기로 겹칠 때만 문제.
## 과윤문 가드 / Do-NOT
- 변경률 30% 초과 = 경고, 50% 초과 = 강제 중단·롤백.
- **탐지·윤문 모두 제외:** 고유명사·제품명·모델명·기관명, 수치·날짜·단위, 큰따옴표 안 직접 인용, 법률 조문, 수학·화학·통계 표기, 업계 표준 영어 약어(LLM·GPU·API·MCP 등).
---
## A. 번역투 (Translation-ese)
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| A-1 | "~에 대해(서)" | S1 | 목적격 조사로 직결("X에 대해 논의" → "X를 논의") |
| A-2 | "~를 통해/통하여" 남발 | S1 | "~로", "~해서", "~함으로써"로 분산 |
| A-3 | "~에 있어(서)" | S1 | "~에서", "~을 볼 때" |
| A-4 | "~라는 점에서" 3회+ | S2 | "~서", "~라는 이유로" |
| A-5 | "~와 관련하여/관련된" | S2 | "~에", "~의" |
| A-6 | 명사화 과잉 / "~에 기반하여/바탕으로" | S2 | 동사로 환원("성능의 향상" → "성능을 높이려고") |
| A-7 | "가지고 있다" / have·make·take·give + N 직역 | S1 | 형용사·동사 환원("경쟁력을 가지고 있다" → "경쟁력이 강하다") |
| A-8 | 이중 피동 "~되어진다/보여진다" | S1 | 능동 또는 단일 피동("판단되어진다" → "판단된다") |
| A-9 | "~에 의해" 피동 | S2 | 행위자를 주어로("AI에 의해 생성" → "AI가 만든") |
| A-10 | "~할 수 있다" 남발 | S2 | 단언으로("높일 수 있다" → "높인다") |
| A-11 | "~을 위해" 목적절 남발 | S2 | "~려고", "~위한" |
| A-15 | 추상 주어 + 만능 동사 / 사역·인지 동사 직역 | S2 | 구체 주어 환원, "suggest/show"는 "~에 따르면 ~이다"로 분리 |
| A-16 | "그/그녀/그것/그들" 한 단락 3회+ (영어 대명사 직역) | S1 | 50%+ 영형(생략) 또는 호칭·명사구로 (김도훈 2009) |
| A-17 | 복수 접미사 "~들" 남발 | S2 | 맥락으로 복수면 삭제("개발자들이" → "개발자가") |
| A-18 | 명사 앞 3어절+ 관형구·관계절 좌향 수식 | S2 | 문장 분리 또는 후치 동격절 (박옥수 2018) |
| A-19 | 이중 조사 "~에서의/~으로의/~에의/~으로부터의" | S2 | 절·구로 풀어쓰기 (김정우 2007) |
## B. 영어 인용·용어 과다
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| B-1 | 한글 + 괄호 영어 매번 병기 | S2 | 첫 등장만 병기, 이후 한글만 |
| B-2 | 직역 가능한 영어 그대로 | S2 | 한국어로 옮기되 업계 표준 약어는 유지 |
## C. 구조적 AI 패턴
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| C-5 | 이모지 남발 | S1 | 칼럼·리포트면 전부 삭제 |
| C-7 | "먼저·반면·결국" 3단 공식 / 3의 법칙 | S2 | 접속사 1~2개로 줄이거나 본문에 녹임 |
| C-8 | "A인가·B인가" 대구 반복 | S2 | 한 번만 살리고 나머지는 평서문으로 |
| C-9 | 숫자 괄호 인덱싱 "(1)·(2)·(3)" | S2 | 본문에 녹이거나 단순 줄바꿈 |
| C-10 | 콜론 부제 헤딩 "X: Y" 반복 | S1 | 헤딩 짧게 또는 평서 헤딩으로 |
| C-11 | 연결어미(-고/-며/-지만/-아서) 직후 쉼표 | S1 | 쉼표 제거. 6회+ = 강한 신호 |
## D. AI 특유의 관용구 (Signature Phrases)
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| D-1 | 결산 피벗 "결론적으로/따라서/이를 통해/요약하면" | S1 | 3회 초과 시 1~2건만 치환, 나머지 삭제 |
| D-2 | "시사하는 바가 크다/주목할 만하다" | S1 | 삭제 또는 구체 결론으로 |
| D-3 | "본질적으로/핵심적으로" | S1 | 삭제 |
| D-4 | hype 어휘(파격적·압도적·획기적·다채로운·진정한·자리매김) 3회+ | S1 | 구체 수치·사실로 환원 |
| D-5 | 의인화 추상 주어("기술이 묻는다·시대가 부른다") | S1 | 사람·기관 주어로 |
| D-6 | 결말 공식 "~할 때다/~해야 한다/지금이야말로" / 막연한 긍정 마무리 | S1 | 평서로 닫거나 삭제 |
| D-7 | 변환 공식 "X에서 Y로" 반복 | S2 | 한 번만, 나머지는 일반 서술 |
| D-8 | 과장된 의의 부여(이정표·산물·새 지평·상징) | S1 | 구체 사실로 환원 |
| D-9 | 출처 없는 권위 호출(전문가들은·~로 알려져 있다) | S2 | 구체 출처 명시 또는 삭제 |
## E. 리듬·종결어미
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| E-1 | 문장 길이 균일(stdev 8 미만) | S2 | 단문·장문을 의도적으로 섞음 |
| E-2 | 동일 종결어미 "~다" 4문장 연속 / "~고 있다" 자동 매핑 | S2 | 종결어미 다양화, 단순 시제 환원 |
| E-7 | 청자 경어법 일관성 손실(대화·구어 한정) | S2 | 한 단락 내 혼용 금지 (김혜영 2019) |
## F. 과도한 수식·중복
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| F-1 | 동의어 돌려쓰기(주인공→캐릭터→인물→그) | S2 | 한 명칭으로 통일 |
| F-2 | 가짜 범위 "A에서 B까지" / 부정 병렬 "단순한 X가 아니라 Y" | S2 | 평서로 환원 |
| F-4 | -성/-적/-화 + 영어 -tion/-ment 누적(한 글 12회+) | S2 | 동사·형용사 어근으로 환원 |
| F-5 | "~적 N" 추상 체인("전략적 함의·실천적 기반") | S2 | 명사+명사 또는 풀어쓰기 |
## G. Hedging
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| G-1 | "~것이다/~할 것이다" 미래 단정 남발 | S2 | 현재형·확정형으로 |
| G-2 | "~로 보인다/~인 듯하다" 추정 남발 | S2 | 단언 가능한 곳은 단언 |
| G-3 | 안전 균형 lexicon "장점도 있지만/신중하게/균형" 4회+ | S2 | 1~2건만 화자 입장으로 치환 |
| G-4 | 지식 한계 면피("공개된 정보가 제한적이지만") | S2 | "자료에 없다" 또는 문장 삭제 |
## H. 접속사 남발
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| H-1 | 문두 접속사 "또한·따라서·즉·나아가·게다가" 5회+ | S1 | 대량 제거, 문장이 흐름을 잡게 |
| H-3 | 메타 진입 "이는·이 점에서·이 관점에서" 3회+ | S1 | 본문에 녹이거나 삭제 |
| H-4 | "즉" 남발 | S2 | 1회로 제한 |
## I. 형식명사·의존명사
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| I-1 | "~인 것이다/~한 것이다" 결말 / 권위적 본질 호명 | S1 | 평서형으로 |
| I-2 | "X은 ~라는 점에 있다" | S2 | "X는 ~다" 직설로 |
| I-3 | "~다는 뜻이다/~다는 의미다" 결말 | S2 | 본문에 풀어 쓰기 |
| I-4 | 설교조 당위 "~해야 한다·~할 필요가 있다" / 예고 멘트 반복 | S2 | 평서·단언으로, 예고 삭제 |
## J. 시각 장식
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| J-1 | 볼드 ** 강조 남발 / 불릿+굵은 머리말 나열 | S2 | 칼럼·리포트면 줄글로 통합 |
| J-2 | 줄표(—)·en dash() / 따옴표 강조 5회+ / 곡선따옴표·물결표 | S1(줄표) | 줄표 제거(마침표·쉼표·괄호로), 강조 한두 개만 |
| J-3 | 불릿 리스트 (장르가 칼럼·리포트일 때) | S2 | 문단 산문으로 통합 |
## 챗봇 잔재
| ID | 패턴 | 심각도 | 처방 |
|---|---|---|---|
| K-1 | 챗봇 응대("좋은 질문이에요!·도움이 되었으면") | S1 | 통째로 삭제 |
| K-2 | 아첨·과잉 공손("정말 훌륭한 지적이세요!") | S1 | 삭제, 본론만 남김 |
---
## 자가검증 체크리스트 (윤문 후, 한 항목 위반 시 해당 edit 롤백)
1. 고유명사·수치·날짜·인용 100% 보존 — 원문 대비 한 글자도 다르지 않은가.
2. 변경률 30% 이하인가 (50% 초과는 작업 중단).
3. 장르 이탈 없음 — 칼럼이 에세이로, 리포트가 블로그체로 떨어지지 않았는가.
4. register 보존 — 원문이 격식체면 결과도 격식체.
5. 잔존 S1 0건 — D-1~D-6·A-7·A-8·A-16·C-5·C-10·C-11·H-1·I-1·J-2.
6. 인공 표현 자제 — 원문에 없던 비유·수사를 임의로 더하지 않았는가.
## 등급 기준 (자가 채점)
- **A**: S1 잔존 0, S2 잔존 2 이하, 변경률 10~25%, 자가검증 6항 통과.
- **B**: S1 잔존 0, S2 잔존 4 이하, 자가검증 5항 이상 통과.
- **C**: S1 잔존 1~2 또는 자가검증 4항 이하 → 2차 윤문 권고.
- **D**: S1 잔존 3+ 또는 변경률 50% 초과 → 작업 중단, 사람 검토 권고.

View file

@ -1,6 +1,6 @@
---
name: korean-law-search
description: Use korean-law-mcp first for Korean law lookups, and fall back to Beopmang when the primary service is unavailable.
description: Search Korean statutes, articles, precedents, interpretations, and local ordinances via k-skill-proxy. Use when the user asks for Korean law/article/precedent lookups.
license: MIT
metadata:
category: legal
@ -12,16 +12,12 @@ metadata:
## What this skill does
한국 법령/조문/판례/유권해석/자치법규 조회가 필요할 때 기본 경로로 **`korean-law-mcp`를 먼저 사용**하고, 기존 서비스가 동작하지 않을 때는 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 이어간다.
기본적으로 `https://k-skill-proxy.nomadamas.org/v1/korean-law/...` 로 요청해서 한국 법령/조문/판례/유권해석/자치법규를 조회한다. 법제처(국가법령정보센터) 공식 Open API(`open.law.go.kr` 의 DRF `lawSearch.do`/`lawService.do`)를 기반으로 하며, 설계는 `chrisryugj/korean-law-mcp` 의 read-only 도구 표면을 참고했다.
- 법령명 검색: `search_law`
- 조문 본문 조회: `get_law_text`
- 판례 검색: `search_precedents`
- 유권해석 검색: `search_interpretations`
- 자치법규 검색: `search_ordinance`
- 여러 카테고리가 섞인 검색: `search_all`
사용자는 별도 API key(`LAW_OC`)나 로컬 CLI 설치가 필요 없다. `LAW_OC` 와 브라우저 User-Agent/Referer 주입은 proxy 서버에서만 처리한다.
이 스킬은 자체 npm/python 패키지를 만들지 않는다. 한국 법령 관련 조회는 기본적으로 `korean-law-mcp` 로 처리하고, 해당 경로가 막히거나 실패가 반복될 때만 승인된 fallback 표면인 `법망`을 사용한다.
- 검색/목록: `GET /v1/korean-law/search`
- 본문/상세: `GET /v1/korean-law/detail`
## When to use
@ -39,136 +35,102 @@ metadata:
## Prerequisites
- 인터넷 연결
- `node` 18+
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
- MCP 클라이언트에 remote endpoint를 등록할 수 있는 환경
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream `LAW_OC` 는 proxy 서버에서만 주입한다.
무료 API key: `https://open.law.go.kr`
## Default path
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
`KSKILL_PROXY_BASE_URL` 환경변수가 있으면 그 값을 사용하고, 없으면 기본 경로 `https://k-skill-proxy.nomadamas.org` 를 사용한다.
## Supported endpoints
### 검색/목록 조회
```
GET /v1/korean-law/search?target={target}&query={검색어}
```
`target` 은 read-only 법령정보 종류다.
| target | 설명 |
|---|---|
| `law` | 현행법령 |
| `eflaw` | 시행일 법령 |
| `elaw` | 영문법령 |
| `prec` | 판례 |
| `detc` | 헌재결정례 |
| `expc` | 법령해석례(유권해석) |
| `admrul` | 행정규칙 |
| `ordin` | 자치법규 |
| `trty` | 조약 |
| `lstrm` | 법령용어 |
지원 필터: `query`(검색어), `display`, `page`, `sort`, `date`, `prncYd`(선고일자), `nb`(사건번호), `datSrcNm`(데이터출처명), `curt`(법원), `org`, `knd`, `gana`, `nw`, `efYd`, `ancYd`. 응답은 법제처 DRF JSON 그대로에 `proxy` 메타데이터만 덧붙인다. 요약 전에 반환 메타데이터를 먼저 확인한다.
### 본문/상세 조회
```
GET /v1/korean-law/detail?target={target}&ID={일련번호}
```
검색 결과의 식별자(`ID` 또는 `MST`/`LID`)를 넘겨 상세 본문을 가져온다. 조문 지정은 `JO`(예: `000200` = 제2조), 언어는 `LANG` 로 넘긴다.
## Example requests
법령명 검색:
```bash
npm install -g korean-law-mcp
export LAW_OC=your-api-key
korean-law list
korean-law help search_law
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=law' \
--data-urlencode 'query=관세법'
```
로컬 설치가 운영체제 정책이나 권한 때문에 막히면 먼저 `korean-law-mcp` 의 remote MCP endpoint(`https://korean-law-mcp.fly.dev/mcp`)를 사용한다. 그래도 기존 경로가 응답하지 않거나 서비스 장애로 조회가 막히면, 승인된 fallback 표면인 `법망` MCP/REST(`https://api.beopmang.org`)로 전환한다.
## MCP client setup
Claude Desktop / Cursor / Windsurf 같은 MCP 클라이언트에는 아래처럼 연결한다.
```json
{
"mcpServers": {
"korean-law": {
"command": "korean-law-mcp",
"env": {
"LAW_OC": "your-api-key"
}
}
}
}
```
설치가 막힌 환경에서는 remote endpoint를 사용한다. 이 upstream 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다.
```json
{
"mcpServers": {
"korean-law": {
"url": "https://korean-law-mcp.fly.dev/mcp"
}
}
}
```
## Fallback workflow (`법망`)
기존 `korean-law-mcp` 경로가 동작하지 않을 때만 아래 fallback을 사용한다.
### 1. MCP fallback
```json
{
"mcpServers": {
"beopmang": {
"url": "https://api.beopmang.org/mcp"
}
}
}
```
### 2. REST fallback
판례 검색:
```bash
curl "https://api.beopmang.org/api/v4/law?action=search&q=관세법"
curl "https://api.beopmang.org/api/v4/tools?action=overview&law_id=001706"
curl "https://api.beopmang.org/api/v4/law?action=get&law_id=001706&article=제750조"
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
--data-urlencode 'target=prec' \
--data-urlencode 'query=부당해고'
```
## CLI workflow
### 1. 법령명부터 찾기
판례 본문 조회:
```bash
korean-law search_law --query "관세법"
```
### 2. 특정 조문 본문 조회
```bash
korean-law get_law_text --mst 160001 --jo "제38조"
```
### 3. 판례 검색
```bash
korean-law search_precedents --query "부당해고"
```
### 4. 자치법규 검색
```bash
korean-law search_ordinance --query "서울특별시 청년 기본 조례"
```
### 5. 애매하면 통합 검색
```bash
korean-law search_all --query "개인정보 처리방침 행정해석"
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
--data-urlencode 'target=prec' \
--data-urlencode 'ID=228541'
```
## Response policy
- 한국 법령 관련 요청은 **항상 `korean-law-mcp`를 먼저 사용**한다.
- 기존 `korean-law-mcp` 경로가 설치/네트워크/서비스 장애로 실패하면 `법망`(`https://api.beopmang.org`)을 fallback으로 사용한다.
- 약칭(`화관법`)이면 `search_law` / `search_all` 로 정식 법령명을 먼저 확인한다.
- 조문 요청이면 검색 결과의 식별자(`mst`)를 확인한 뒤 `get_law_text` 로 본문을 가져온다.
- 판례`search_precedents`, 유권해석은 `search_interpretations`, 자치법규는 `search_ordinance` 를 우선 사용한다.
- 로컬 CLI/MCP 경로를 쓰는데 `LAW_OC` 가 없으면 credential resolution order에 따라 확보 방법을 짧게 안내하고, 임의의 크롤링/검색엔진 우회로 넘어가지 않는다.
- remote MCP endpoint를 쓰면 사용자 `LAW_OC` 없이 `url` 등록 상태만 확인한다.
- 한국 법령 관련 요청은 이 proxy endpoint로 처리한다. 별도 크롤러나 검색엔진 우회로 넘어가지 않는다.
- 약칭(`화관법`)이면 `target=law` 로 정식 법령명을 먼저 확인한다.
- 조문 요청이면 검색 결과의 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 로 본문을 가져온다.
- 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
- 판례 본문이 필요하면 검색 결과의 판례 일련번호를 `detail?target=prec&ID=...` 로 이어서 조회한다.
- 검색 결과가 0건이어도 "관련 규범이 없다"고 단정하지 말고 검색어·법원·사건번호·선고일자·출처명을 바꿔 다시 시도한다.
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다(없는 본문을 지어내지 않는다).
- 법적 판단이 필요한 경우 `검색 결과 요약``원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
## Failure modes
- `target` 이 없거나 허용되지 않은 값이면 400 응답
- 검색어/식별자가 없으면 400 응답
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
- 법제처 API가 사용자 검증 실패(`사용자 정보 검증 실패`)를 반환하면 502 + `law_user_verification_failed` (서버 OC/UA/Referer 점검)
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
## Done when
- 한국 법령 관련 질의에 대해 `korean-law-mcp` 사용 경로가 선택되었다.
- 필요한 검색/조회 명령이 정해졌다.
- 법령/조문/판례/유권해석/자치법규 중 맞는 도구로 결과를 조회했다.
- 유권해석이면 `search_interpretations`, 자치법규면 `search_ordinance` 까지 명시적으로 연결했다.
- 로컬 경로라면 `LAW_OC` 확보 방법을 정확한 변수 이름으로 안내했다.
- remote endpoint라면 사용자 `LAW_OC` 없이 `url` 등록 상태를 확인했다.
- 기존 경로 장애 시 `법망` fallback(MCP 또는 REST)으로 이어지는 안내가 포함되었다.
- 한국 법령 관련 질의를 proxy endpoint로 라우팅했다.
- 법령/조문은 `target=law` + 필요 시 `detail`, 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 맞는 종류를 조회했다.
- 판례/조문 본문이 필요하면 식별자로 `detail` 본문까지 연결했다.
- 결과를 요약하고 원문 출처(법제처 국가법령정보센터)를 함께 남겼다.
## Notes
- upstream: `https://github.com/chrisryugj/korean-law-mcp`
- fallback surface: `https://api.beopmang.org`
- official data source: 법제처 Open API (`https://open.law.go.kr`)
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
- official data source: 법제처 Open API (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요). 무료 발급: `https://open.law.go.kr`
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.

View file

@ -11,10 +11,10 @@
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"typecheck": "tsc --noEmit",
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --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 hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/korean-law.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/korean-law.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -0,0 +1,313 @@
// k-skill-proxy wrapper for the official 법제처 (Korea Ministry of Government
// Legislation) Open API "공동활용" DRF endpoints.
//
// Design notes:
// - Mirrors the read-only legal-info surface that chrisryugj/korean-law-mcp
// wraps (https://github.com/chrisryugj/korean-law-mcp), but exposes it as a
// hosted REST proxy so skills do not need a per-user OC key or a local CLI.
// - The OC identifier is injected server-side from the LAW_OC secret. It is the
// only credential the upstream needs.
// - law.go.kr rejects requests that lack a browser User-Agent / Referer with a
// "사용자 정보 검증에 실패" body even when the OC is valid. We always inject
// both headers (overridable via LAW_USER_AGENT / LAW_REFERER).
// - law.go.kr also intermittently answers 200 with an empty body or an HTML
// maintenance page; we retry those as transient failures.
// - Read-only: only lawSearch.do (list/search) and lawService.do (detail/body)
// are reachable. No mutation surface exists in the upstream API.
const KOREAN_LAW_API_BASE_URL = "https://www.law.go.kr/DRF";
const DEFAULT_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
const DEFAULT_REFERER = "https://www.law.go.kr/";
const REQUEST_TIMEOUT_MS = 20000;
const MAX_ATTEMPTS = 3;
const RETRY_BACKOFF_MS = 300;
// Read-only legal-info targets we are willing to proxy.
const ALLOWED_TARGETS = new Set([
"law", // 현행법령
"eflaw", // 시행일 법령
"elaw", // 영문법령
"prec", // 판례
"detc", // 헌재결정례
"expc", // 법령해석례 (유권해석)
"admrul", // 행정규칙
"ordin", // 자치법규
"trty", // 조약
"lstrm", // 법령용어
"lsHstInf" // 법령 연혁
]);
const ALLOWED_TYPES = new Set(["JSON", "XML", "HTML"]);
// Pass-through query params for lawSearch.do (list/search).
const SEARCH_PASSTHROUGH_PARAMS = [
"query",
"search",
"display",
"page",
"sort",
"date",
"prncYd",
"nb",
"datSrcNm",
"curt",
"org",
"knd",
"gana",
"nw",
"efYd",
"ancYd"
];
// Pass-through query params for lawService.do (detail/body).
const DETAIL_PASSTHROUGH_PARAMS = ["ID", "MST", "LID", "LM", "JO", "LANG", "chrClsCd", "ancYnChk"];
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed === "" ? null : trimmed;
}
function buildError({ message, statusCode, code }) {
const error = new Error(message);
error.statusCode = statusCode;
error.code = code;
return error;
}
function normalizeTarget(query) {
const target = trimOrNull(query.target);
if (!target) {
throw buildError({
message: "target is required (e.g. law, prec, expc, admrul, ordin).",
statusCode: 400,
code: "bad_request"
});
}
if (!ALLOWED_TARGETS.has(target)) {
throw buildError({
message: `Unsupported target "${target}". Allowed: ${[...ALLOWED_TARGETS].join(", ")}.`,
statusCode: 400,
code: "bad_request"
});
}
return target;
}
function normalizeType(query) {
const raw = trimOrNull(query.type);
if (!raw) {
return "JSON";
}
const upper = raw.toUpperCase();
if (!ALLOWED_TYPES.has(upper)) {
throw buildError({
message: `Unsupported type "${raw}". Allowed: ${[...ALLOWED_TYPES].join(", ")}.`,
statusCode: 400,
code: "bad_request"
});
}
return upper;
}
function collectPassthrough(query, allowedKeys) {
const params = {};
for (const key of allowedKeys) {
const value = trimOrNull(query[key]);
if (value !== null) {
params[key] = value;
}
}
return params;
}
function normalizeKoreanLawSearchQuery(query = {}) {
const target = normalizeTarget(query);
const type = normalizeType(query);
const params = collectPassthrough(query, SEARCH_PASSTHROUGH_PARAMS);
if (!params.query && !params.search && !params.nb && !params.datSrcNm) {
throw buildError({
message: "A search query is required (provide query, nb, or datSrcNm).",
statusCode: 400,
code: "bad_request"
});
}
return { target, type, params };
}
function normalizeKoreanLawDetailQuery(query = {}) {
const target = normalizeTarget(query);
const type = normalizeType(query);
const params = collectPassthrough(query, DETAIL_PASSTHROUGH_PARAMS);
if (!params.ID && !params.MST && !params.LID) {
throw buildError({
message: "A detail identifier is required (provide ID, MST, or LID).",
statusCode: 400,
code: "bad_request"
});
}
return { target, type, params };
}
function buildKoreanLawUrl({ endpoint, target, type, params, oc }) {
const path = endpoint === "detail" ? "lawService.do" : "lawSearch.do";
const url = new URL(`${KOREAN_LAW_API_BASE_URL}/${path}`);
url.searchParams.set("OC", oc);
url.searchParams.set("target", target);
url.searchParams.set("type", type);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
return url.toString();
}
function looksLikeHtml(body, contentType) {
if (contentType.includes("text/html")) {
return true;
}
return /^\s*<(?:!doctype|html)\b/i.test(body);
}
function isUserVerificationFailure(body) {
return /사용자\s*정보\s*검증|검증에\s*실패|IP주소\s*및\s*도메인/.test(body);
}
async function delay(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchKoreanLaw(url, { userAgent, referer, fetchImpl = global.fetch, sleep = delay, expectJson = true } = {}) {
const headers = {
"User-Agent": userAgent || DEFAULT_USER_AGENT,
Referer: referer || DEFAULT_REFERER,
Accept: expectJson ? "application/json, text/plain, */*" : "*/*"
};
let lastError = null;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetchImpl(url, {
headers,
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
});
const body = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
const trimmed = body.trim();
if (!response.ok) {
return { statusCode: response.status, contentType, body };
}
const transientEmpty = trimmed === "";
const transientHtml = expectJson && looksLikeHtml(trimmed, contentType);
if (transientEmpty || transientHtml) {
lastError = buildError({
message: "law.go.kr returned an empty or HTML maintenance response.",
statusCode: 502,
code: "upstream_unstable"
});
} else {
return { statusCode: 200, contentType, body };
}
} catch (error) {
lastError = error;
}
if (attempt < MAX_ATTEMPTS - 1) {
await sleep(RETRY_BACKOFF_MS * (attempt + 1));
}
}
throw (
lastError ||
buildError({
message: "law.go.kr request failed.",
statusCode: 502,
code: "upstream_error"
})
);
}
async function proxyKoreanLawRequest({
endpoint,
normalized,
oc,
userAgent = null,
referer = null,
fetchImpl = global.fetch,
sleep = delay
}) {
if (!oc) {
return {
statusCode: 503,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "upstream_not_configured",
message: "LAW_OC is not configured on the proxy server."
})
};
}
const url = buildKoreanLawUrl({
endpoint,
target: normalized.target,
type: normalized.type,
params: normalized.params,
oc
});
try {
const result = await fetchKoreanLaw(url, {
userAgent,
referer,
fetchImpl,
sleep,
expectJson: normalized.type === "JSON"
});
if (result.statusCode >= 200 && result.statusCode < 300 && isUserVerificationFailure(result.body)) {
return {
statusCode: 502,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: "law_user_verification_failed",
message:
"law.go.kr rejected the proxy request (사용자 정보 검증 실패). Check LAW_OC and the LAW_USER_AGENT/LAW_REFERER headers on the proxy server."
})
};
}
return result;
} catch (error) {
return {
statusCode: error.statusCode && error.statusCode >= 400 ? error.statusCode : 502,
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
error: error.code || "proxy_error",
message: error.message
})
};
}
}
module.exports = {
KOREAN_LAW_API_BASE_URL,
DEFAULT_USER_AGENT,
DEFAULT_REFERER,
ALLOWED_TARGETS,
ALLOWED_TYPES,
buildKoreanLawUrl,
fetchKoreanLaw,
isUserVerificationFailure,
normalizeKoreanLawDetailQuery,
normalizeKoreanLawSearchQuery,
proxyKoreanLawRequest
};

View file

@ -42,6 +42,11 @@ const {
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
const {
normalizeKoreanLawDetailQuery,
normalizeKoreanLawSearchQuery,
proxyKoreanLawRequest
} = require("./korean-law");
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
@ -184,6 +189,9 @@ function buildConfig(env = process.env) {
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
lawOc: trimOrNull(env.LAW_OC),
lawReferer: trimOrNull(env.LAW_REFERER),
lawUserAgent: trimOrNull(env.LAW_USER_AGENT),
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
@ -1888,7 +1896,8 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
naverSearchApiConfigured: naverSearchKeysPresent,
naverNewsApiConfigured: naverSearchKeysPresent,
ntsBusinessConfigured: Boolean(config.molitApiKey),
kstartupConfigured: Boolean(config.molitApiKey)
kstartupConfigured: Boolean(config.molitApiKey),
koreanLawConfigured: Boolean(config.lawOc)
},
auth: {
tokenRequired: false
@ -3515,6 +3524,97 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply
}));
async function handleKoreanLawRoute({ endpoint, normalize, cacheRoute, request, reply }) {
let normalized;
try {
normalized = normalize(request.query || {});
} catch (error) {
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 400);
return {
error: error.code || "bad_request",
message: error.message
};
}
const cacheKey = makeCacheKey({
route: cacheRoute,
target: normalized.target,
type: normalized.type,
params: normalized.params
});
const cached = cache.get(cacheKey);
if (cached) {
if (typeof cached === "object" && cached.body !== undefined) {
reply.code(cached.statusCode);
reply.header("content-type", cached.contentType);
return cached.body;
}
return {
...cached,
proxy: {
...cached.proxy,
cache: { hit: true, ttl_ms: config.cacheTtlMs }
}
};
}
const upstream = await proxyKoreanLawRequest({
endpoint,
normalized,
oc: config.lawOc,
userAgent: config.lawUserAgent,
referer: config.lawReferer
});
reply.code(upstream.statusCode);
reply.header("content-type", upstream.contentType);
if (!upstream.contentType.includes("json")) {
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(
cacheKey,
{ statusCode: upstream.statusCode, contentType: upstream.contentType, body: upstream.body },
config.cacheTtlMs
);
}
return upstream.body;
}
const payload = JSON.parse(upstream.body);
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
payload.proxy = {
name: config.proxyName,
cache: { hit: false, ttl_ms: config.cacheTtlMs },
requested_at: new Date().toISOString()
};
}
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
cache.set(cacheKey, payload, config.cacheTtlMs);
}
return payload;
}
app.get("/v1/korean-law/search", async (request, reply) =>
handleKoreanLawRoute({
endpoint: "search",
normalize: normalizeKoreanLawSearchQuery,
cacheRoute: "korean-law-search",
request,
reply
}));
app.get("/v1/korean-law/detail", async (request, reply) =>
handleKoreanLawRoute({
endpoint: "detail",
normalize: normalizeKoreanLawDetailQuery,
cacheRoute: "korean-law-detail",
request,
reply
}));
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
let normalized;

View file

@ -0,0 +1,208 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_REFERER,
DEFAULT_USER_AGENT,
buildKoreanLawUrl,
fetchKoreanLaw,
isUserVerificationFailure,
normalizeKoreanLawDetailQuery,
normalizeKoreanLawSearchQuery,
proxyKoreanLawRequest
} = require("../src/korean-law");
const noopSleep = async () => {};
function jsonResponse(body, { status = 200, contentType = "application/json; charset=utf-8" } = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name) => (name.toLowerCase() === "content-type" ? contentType : null) },
text: async () => (typeof body === "string" ? body : JSON.stringify(body))
};
}
test("normalizeKoreanLawSearchQuery requires a target", () => {
assert.throws(() => normalizeKoreanLawSearchQuery({ query: "관세법" }), /target is required/);
});
test("normalizeKoreanLawSearchQuery rejects an unsupported target", () => {
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "evil", query: "x" }), /Unsupported target/);
});
test("normalizeKoreanLawSearchQuery requires a search query", () => {
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law" }), /search query is required/);
});
test("normalizeKoreanLawSearchQuery keeps only allowlisted params and defaults type to JSON", () => {
const normalized = normalizeKoreanLawSearchQuery({
target: "prec",
query: "부당해고",
display: "5",
curt: "대법원",
evil: "drop-me"
});
assert.equal(normalized.target, "prec");
assert.equal(normalized.type, "JSON");
assert.deepEqual(normalized.params, { query: "부당해고", display: "5", curt: "대법원" });
});
test("normalizeKoreanLawDetailQuery requires an identifier", () => {
assert.throws(() => normalizeKoreanLawDetailQuery({ target: "prec" }), /detail identifier is required/);
});
test("normalizeKoreanLawDetailQuery accepts ID and passthrough params", () => {
const normalized = normalizeKoreanLawDetailQuery({ target: "prec", ID: "228541", JO: "0002", evil: "x" });
assert.deepEqual(normalized.params, { ID: "228541", JO: "0002" });
});
test("normalizeType rejects unsupported types", () => {
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law", query: "x", type: "csv" }), /Unsupported type/);
});
test("buildKoreanLawUrl injects OC, target, type and routes search vs detail", () => {
const searchUrl = buildKoreanLawUrl({
endpoint: "search",
target: "prec",
type: "JSON",
params: { query: "부당해고" },
oc: "secret-oc"
});
assert.match(searchUrl, /\/DRF\/lawSearch\.do\?/);
assert.match(searchUrl, /OC=secret-oc/);
assert.match(searchUrl, /target=prec/);
assert.match(searchUrl, /type=JSON/);
assert.match(searchUrl, /query=%EB%B6%80%EB%8B%B9%ED%95%B4%EA%B3%A0/);
const detailUrl = buildKoreanLawUrl({
endpoint: "detail",
target: "prec",
type: "JSON",
params: { ID: "228541" },
oc: "secret-oc"
});
assert.match(detailUrl, /\/DRF\/lawService\.do\?/);
assert.match(detailUrl, /ID=228541/);
});
test("isUserVerificationFailure detects the law.go.kr rejection body", () => {
assert.equal(isUserVerificationFailure('{"result":"사용자 정보 검증에 실패하였습니다."}'), true);
assert.equal(isUserVerificationFailure('{"PrecSearch":{}}'), false);
});
test("fetchKoreanLaw sends browser User-Agent and Referer headers", async () => {
let sentHeaders = null;
const fetchImpl = async (_url, options) => {
sentHeaders = options.headers;
return jsonResponse({ PrecSearch: { prec: [] } });
};
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
assert.equal(sentHeaders["User-Agent"], DEFAULT_USER_AGENT);
assert.equal(sentHeaders.Referer, DEFAULT_REFERER);
});
test("fetchKoreanLaw honors custom User-Agent and Referer overrides", async () => {
let sentHeaders = null;
const fetchImpl = async (_url, options) => {
sentHeaders = options.headers;
return jsonResponse({ ok: true });
};
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", {
fetchImpl,
sleep: noopSleep,
userAgent: "custom-ua",
referer: "https://example.test/"
});
assert.equal(sentHeaders["User-Agent"], "custom-ua");
assert.equal(sentHeaders.Referer, "https://example.test/");
});
test("fetchKoreanLaw retries empty/HTML responses then succeeds", async () => {
let calls = 0;
const fetchImpl = async () => {
calls += 1;
if (calls === 1) {
return jsonResponse("", { contentType: "application/json" });
}
if (calls === 2) {
return jsonResponse("<html><body>maintenance</body></html>", { contentType: "text/html" });
}
return jsonResponse({ LawSearch: { law: [{ id: "1" }] } });
};
const result = await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
assert.equal(calls, 3);
assert.match(result.body, /LawSearch/);
});
test("fetchKoreanLaw throws after exhausting retries on persistent empty bodies", async () => {
const fetchImpl = async () => jsonResponse("", { contentType: "application/json" });
await assert.rejects(
() => fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep }),
/empty or HTML/
);
});
test("proxyKoreanLawRequest returns 503 when LAW_OC is not configured", async () => {
const result = await proxyKoreanLawRequest({
endpoint: "search",
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
oc: null,
sleep: noopSleep
});
assert.equal(result.statusCode, 503);
assert.match(result.body, /upstream_not_configured/);
});
test("proxyKoreanLawRequest passes the OC through to the upstream URL", async () => {
let calledUrl = null;
const fetchImpl = async (url) => {
calledUrl = String(url);
return jsonResponse({ LawSearch: { law: [] } });
};
const result = await proxyKoreanLawRequest({
endpoint: "search",
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
oc: "secret-oc",
fetchImpl,
sleep: noopSleep
});
assert.equal(result.statusCode, 200);
assert.match(calledUrl, /OC=secret-oc/);
assert.match(calledUrl, /\/lawSearch\.do\?/);
});
test("proxyKoreanLawRequest maps a user-verification body to a 502 error", async () => {
const fetchImpl = async () => jsonResponse({ result: "사용자 정보 검증에 실패하였습니다." });
const result = await proxyKoreanLawRequest({
endpoint: "search",
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
oc: "secret-oc",
fetchImpl,
sleep: noopSleep
});
assert.equal(result.statusCode, 502);
assert.match(result.body, /law_user_verification_failed/);
});
test("proxyKoreanLawRequest surfaces upstream non-2xx responses verbatim", async () => {
const fetchImpl = async () => jsonResponse("server error", { status: 500, contentType: "text/plain" });
const result = await proxyKoreanLawRequest({
endpoint: "detail",
normalized: { target: "prec", type: "JSON", params: { ID: "228541" } },
oc: "secret-oc",
fetchImpl,
sleep: noopSleep
});
assert.equal(result.statusCode, 500);
});

View file

@ -5669,3 +5669,139 @@ test("K-Startup integer fields reject non-numeric input before upstream call", a
}
assert.equal(called, false, "upstream must not be called for any invalid integer input");
});
test("korean-law search endpoint proxies law.go.kr with the server OC and browser headers", async (t) => {
const originalFetch = global.fetch;
let calledUrl = null;
let calledHeaders = null;
global.fetch = async (url, options) => {
calledUrl = String(url);
calledHeaders = options.headers;
return new Response(JSON.stringify({ PrecSearch: { prec: [{ 사건번호: "2023두54914" }] } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-law/search?target=prec&query=%EB%B6%80%EB%8B%B9%ED%95%B4%EA%B3%A0"
});
assert.equal(response.statusCode, 200);
assert.equal(response.json().PrecSearch.prec[0].사건번호, "2023두54914");
assert.equal(response.json().proxy.cache.hit, false);
assert.match(calledUrl, /\/DRF\/lawSearch\.do\?/);
assert.match(calledUrl, /OC=server-oc/);
assert.match(calledUrl, /target=prec/);
assert.ok(calledHeaders["User-Agent"].includes("Mozilla/5.0"));
assert.equal(calledHeaders.Referer, "https://www.law.go.kr/");
});
test("korean-law search endpoint caches successful upstream responses", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ LawSearch: { law: [] } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const url = "/v1/korean-law/search?target=law&query=%EA%B4%80%EC%84%B8%EB%B2%95";
const first = await app.inject({ method: "GET", url });
const second = await app.inject({ method: "GET", url });
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
});
test("korean-law detail endpoint routes to lawService.do", async (t) => {
const originalFetch = global.fetch;
let calledUrl = null;
global.fetch = async (url) => {
calledUrl = String(url);
return new Response(JSON.stringify({ PrecService: { 판례정보일련번호: "228541" } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
const app = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-law/detail?target=prec&ID=228541"
});
assert.equal(response.statusCode, 200);
assert.match(calledUrl, /\/DRF\/lawService\.do\?/);
assert.match(calledUrl, /ID=228541/);
});
test("korean-law search endpoint returns 400 for a missing query", async (t) => {
const originalFetch = global.fetch;
let called = false;
global.fetch = async () => {
called = true;
return new Response("{}", { status: 200 });
};
const app = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({ method: "GET", url: "/v1/korean-law/search?target=law" });
assert.equal(response.statusCode, 400);
assert.equal(called, false);
});
test("korean-law search endpoint returns 503 when the proxy server lacks LAW_OC", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/korean-law/search?target=law&query=%EA%B4%80%EC%84%B8%EB%B2%95"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("health endpoint reports koreanLawConfigured from LAW_OC", async (t) => {
const off = buildServer();
const on = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
await off.close();
await on.close();
});
const offBody = (await off.inject({ method: "GET", url: "/health" })).json();
const onBody = (await on.inject({ method: "GET", url: "/health" })).json();
assert.equal(offBody.upstreams.koreanLawConfigured, false);
assert.equal(onBody.upstreams.koreanLawConfigured, true);
});

View file

@ -1,10 +1,95 @@
# toss-securities
`JungHoonGhae/tossinvest-cli``tossctl` 바이너리를 감싸는 **read-only tossctl wrapper** 입니다. 이 패키지는 설치/로그인/조회 흐름만 정리하고, 거래 mutation 은 공개 API에서 지원하지 않습니다.
토스증권 **조회 전용(read-only)** 클라이언트입니다. 두 경로를 제공합니다.
## Install
1. **공식 Open API (권장 / primary)** — 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 직접 호출합니다.
2. **tossctl fallback** — 공식 API credentials가 없을 때를 위한 비공식 `JungHoonGhae/tossinvest-cli``tossctl` **read-only tossctl wrapper** 입니다.
먼저 upstream CLI 를 설치합니다.
두 경로 모두 조회 전용입니다. 거래 mutation(주문 생성/정정/취소)은 의도적으로 래핑하지 않습니다.
## 1. 공식 Open API (권장)
### Credentials
토스증권 OpenAPI 콘솔에서 클라이언트를 등록해 `client_id` / `client_secret` 을 발급받습니다. 자격 증명은 **사용자 본인의 환경변수**로 두고, helper가 `https://openapi.tossinvest.com` 으로 **직접** 호출합니다. 공유 프록시(k-skill-proxy)로는 절대 라우팅하지 않습니다.
| 환경변수 | 필수 | 설명 |
|---|---|---|
| `TOSSINVEST_CLIENT_ID` | 필수 | 발급받은 client id |
| `TOSSINVEST_CLIENT_SECRET` | 필수 | 발급받은 client secret |
| `TOSSINVEST_ACCOUNT` | 선택 | `X-Tossinvest-Account` 에 쓸 accountSeq. 계좌·자산·주문조회 helper에 필요 |
| `TOSSINVEST_API_BASE_URL` | 선택 | 기본 `https://openapi.tossinvest.com` |
per-call 옵션(`{ clientId, clientSecret, account, baseUrl }`)이 환경변수보다 우선합니다.
### 토큰 흐름
helper들은 내부적으로 `POST /oauth2/token` (Client Credentials, `application/x-www-form-urlencoded`)으로 access token을 발급받아 `Authorization: Bearer {token}` 헤더로 호출합니다. 토큰은 프로세스 전역(in-memory) 캐시에 `client_id::base_url` 키로 보관되며 만료 60초 전에 자동 재발급됩니다. 테스트 등에서 캐시를 비우려면 `clearTokenCache()` 를 호출합니다.
> 보안: `client_secret` 와 access token은 throw되는 에러 메시지/`data`에서 항상 `[REDACTED]` 로 마스킹됩니다. 토큰 캐시는 같은 Node 프로세스 안에서 공유됩니다.
### 시세·종목 helper (토큰만 필요)
- `getOrderbook(symbol)``GET /api/v1/orderbook`
- `getPrices(symbols)``GET /api/v1/prices` (다건은 콤마로 연결, 최대 200)
- `getTrades(symbol, { count })``GET /api/v1/trades`
- `getPriceLimits(symbol)``GET /api/v1/price-limits`
- `getCandles(symbol, { interval })``GET /api/v1/candles` (`interval``1m`·`1d`, 필수)
- `getStocks(symbols)``GET /api/v1/stocks`
- `getStockWarnings(symbol)``GET /api/v1/stocks/{symbol}/warnings`
- `getExchangeRate({ from, to })``GET /api/v1/exchange-rate`
- `getMarketCalendarKR({ date })``GET /api/v1/market-calendar/KR`
- `getMarketCalendarUS({ date })``GET /api/v1/market-calendar/US`
### 계좌·자산·주문조회 helper (토큰 + `X-Tossinvest-Account`)
- `listOfficialAccounts()``GET /api/v1/accounts` (accountSeq를 얻는 진입점, 토큰만 필요)
- `getHoldings({ symbol })``GET /api/v1/holdings`
- `listOpenOrders()``GET /api/v1/orders` (대기중 주문)
- `getOrderDetail(orderId)``GET /api/v1/orders/{orderId}`
- `getBuyingPower({ currency })``GET /api/v1/buying-power`
- `getSellableQuantity(symbol)``GET /api/v1/sellable-quantity`
- `getCommissions()``GET /api/v1/commissions`
각 helper는 `{ data, rateLimit: { limit, remaining, reset }, requestId, status }` 를 반환합니다. account 헤더가 필요한 helper에서 account가 없으면 네트워크 호출 전에 `TossCredentialsError` 를 던집니다.
### Rate limit / 에러
- `429` 응답은 `Retry-After` (없으면 `X-RateLimit-Reset`) 만큼 대기 후 지수 백오프(1→2→4초)+jitter로 재시도하며 `maxRetries`(기본 3)에서 멈춥니다.
- `401`(`invalid-token`/`expired-token`)은 토큰을 1회 재발급해 재시도하고, 그래도 실패하면 throw합니다.
- 에러 envelope `{ error: { requestId, code, message, data } }``TossApiError{ code, message, requestId, httpStatus, data }` 로 변환됩니다. `requestId` 는 본문에 없으면 `X-Request-Id` 헤더에서 가져옵니다.
### 사용 예시
```js
const {
getPrices,
getHoldings,
listOfficialAccounts
} = require("toss-securities");
async function main() {
// 환경변수 TOSSINVEST_CLIENT_ID / TOSSINVEST_CLIENT_SECRET 필요
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
console.log(prices.data);
console.log(holdings.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 2. tossctl fallback (read-only tossctl wrapper)
공식 API credentials가 없으면 비공식 `tossctl` 경로를 fallback으로 쓸 수 있습니다. 먼저 upstream CLI를 설치합니다.
중요: `tossctl >= 0.3.6` 사용을 권장합니다. (`quote` 403 / 세션 관련 upstream 이슈 #15 반영 버전)
@ -22,60 +107,26 @@ tossctl auth login
npm install toss-securities
```
## Supported read-only helpers
### tossctl read-only helpers
- `listAccounts()`
- `getAccountSummary()`
- `getAccountSummary()``tossctl account summary --output json`
- `getPortfolioPositions()`
- `getPortfolioAllocation()`
- `getQuote(symbol)`
- `getQuote(symbol)``tossctl quote get TSLA --output json`
- `getQuoteBatch(symbols)`
- `listOrders()`
- `listCompletedOrders({ market })`
- `listWatchlist()`
- `listWatchlist()``tossctl watchlist list --output json`
- `checkSession()`
모든 helper 는 내부적으로 `tossctl ... --output json` 을 실행하고, `commandName`, `bin`, `args`, `data` 를 반환합니다.
모든 tossctl helper는 내부적으로 `tossctl ... --output json` 을 실행하고 `commandName`, `bin`, `args`, `data` 를 반환합니다. 세션이 만료되면 `TossSessionExpiredError` 로 승격됩니다.
세션 만료 관련:
- `account summary` 등은 만료 시 에러를 던집니다.
- 일부 커맨드(`portfolio positions`, `watchlist list`)는 upstream에서 빈 배열(`[]`)을 반환할 수 있어, 이 패키지는 기본적으로 `auth doctor`를 추가 확인해 만료를 `TossSessionExpiredError`로 승격합니다.
- 필요하면 `verifySessionOnEmpty: false`로 기존 빈 배열 동작을 유지할 수 있습니다.
## 지원하지 않는 것 (not supported)
대응되는 대표 CLI 는 `tossctl account summary --output json`, `tossctl quote get TSLA --output json`, `tossctl watchlist list --output json` 입니다.
## Usage
```js
const {
getAccountSummary,
getQuote,
listWatchlist
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary({
configDir: "/Users/me/.config/tossctl"
});
const quote = await getQuote("TSLA");
const watchlist = await listWatchlist();
console.log(summary.data);
console.log(quote.data);
console.log(watchlist.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## What is intentionally not supported
- `tossctl order place`
- `tossctl order cancel`
- `tossctl order amend`
- `tossctl order place` / 공식 API `POST /api/v1/orders` (주문 생성)
- `tossctl order cancel` / 공식 API `POST /api/v1/orders/{orderId}/cancel`
- `tossctl order amend` / 공식 API `POST /api/v1/orders/{orderId}/modify`
- permission grant/revoke
이 패키지는 조회 전용이다. 실거래에 영향을 주는 명령은 upstream safety gate 를 우회하지 않도록 래핑하지 않는다.
이 패키지는 조회 전용이다. 실거래에 영향을 주는 명령은 공식/비공식 어느 경로에서도 래핑하지 않는다.

View file

@ -1,7 +1,7 @@
{
"name": "toss-securities",
"version": "0.4.0",
"description": "Safe read-only tossctl wrapper for Toss Securities skill workflows",
"description": "Read-only Toss Securities client: official Open API (OAuth2) first, unofficial tossctl wrapper as fallback",
"license": "MIT",
"main": "src/index.js",
"files": [
@ -23,10 +23,12 @@
"korea",
"toss",
"securities",
"tossinvest",
"openapi",
"tossctl"
],
"scripts": {
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
"lint": "node --check src/index.js && node --check src/parse.js && node --check src/official-client.js && node --check test/index.test.js && node --check test/official-client.test.js",
"test": "node --test"
}
}

View file

@ -6,6 +6,8 @@ const {
parseJsonOutput
} = require("./parse");
const officialClient = require("./official-client");
const execFile = util.promisify(childProcess.execFile);
const SESSION_EXPIRED_PATTERN = /stored session is no longer valid/iu;
@ -183,6 +185,7 @@ function getQuoteBatch(symbols, options = {}) {
}
module.exports = {
// Unofficial tossctl wrapper (fallback path)
buildReadOnlyCommand,
checkSession,
getAccountSummary,
@ -195,5 +198,7 @@ module.exports = {
listOrders,
listWatchlist,
runReadOnlyCommand,
TossSessionExpiredError
TossSessionExpiredError,
// Official Toss Securities Open API client (primary path)
...officialClient
};

View file

@ -0,0 +1,530 @@
"use strict";
// Official Toss Securities Open API client (read-only).
//
// Source of truth: https://openapi.tossinvest.com/openapi-docs/latest/openapi.json
// (title "토스증권 Open API", version 1.0.3, server https://openapi.tossinvest.com).
//
// Security posture:
// - Credentials (client id/secret, account) are read from the user's environment
// and sent directly to the official API. They are NEVER routed through any
// shared proxy (k-skill-proxy is free-API-only).
// - client_secret and access tokens are redacted from thrown error messages.
// - This module is read-only: it implements GET endpoints plus the OAuth token
// issuance required to call them. It deliberately exposes NO order mutation
// (no place/modify/cancel order) functions.
const OFFICIAL_BASE_URL = "https://openapi.tossinvest.com";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const DEFAULT_MAX_RETRIES = 3;
const DEFAULT_BACKOFF_BASE_MS = 1000;
// Endpoint registry. `requiresAccount` marks the account/asset/order surfaces
// that additionally need the `X-Tossinvest-Account` header. Note `/api/v1/accounts`
// is bearer-only: it is the entry point used to discover the accountSeq.
const ENDPOINTS = Object.freeze({
// Market data (bearer only)
getOrderbook: { path: "/api/v1/orderbook", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getPrices: { path: "/api/v1/prices", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getTrades: { path: "/api/v1/trades", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getPriceLimits: { path: "/api/v1/price-limits", requiresAccount: false, rateLimitGroup: "MARKET_DATA" },
getCandles: { path: "/api/v1/candles", requiresAccount: false, rateLimitGroup: "MARKET_DATA_CHART" },
// Stock info (bearer only)
getStocks: { path: "/api/v1/stocks", requiresAccount: false, rateLimitGroup: "STOCK" },
getStockWarnings: { path: "/api/v1/stocks/{symbol}/warnings", requiresAccount: false, rateLimitGroup: "STOCK" },
// Market info (bearer only)
getExchangeRate: { path: "/api/v1/exchange-rate", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
getMarketCalendarKR: { path: "/api/v1/market-calendar/KR", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
getMarketCalendarUS: { path: "/api/v1/market-calendar/US", requiresAccount: false, rateLimitGroup: "MARKET_INFO" },
// Account / asset
listOfficialAccounts: { path: "/api/v1/accounts", requiresAccount: false, rateLimitGroup: "ACCOUNT" },
getHoldings: { path: "/api/v1/holdings", requiresAccount: true, rateLimitGroup: "ASSET" },
// Order history (read-only)
listOpenOrders: { path: "/api/v1/orders", requiresAccount: true, rateLimitGroup: "ORDER_HISTORY" },
getOrderDetail: { path: "/api/v1/orders/{orderId}", requiresAccount: true, rateLimitGroup: "ORDER_HISTORY" },
// Order info (read-only)
getBuyingPower: { path: "/api/v1/buying-power", requiresAccount: true, rateLimitGroup: "ORDER_INFO" },
getSellableQuantity: { path: "/api/v1/sellable-quantity", requiresAccount: true, rateLimitGroup: "ORDER_INFO" },
getCommissions: { path: "/api/v1/commissions", requiresAccount: true, rateLimitGroup: "ORDER_INFO" }
});
// Process-global token cache, keyed by `${clientId}::${baseUrl}`. By design the
// cache is shared across all callers in a single Node process; call
// `clearTokenCache()` to reset it (tests do this between cases).
const tokenCache = new Map();
class TossApiError extends Error {
constructor({ code, message, requestId, httpStatus, data } = {}, secrets = []) {
super(redact(`[${code}] ${message}`, secrets));
this.name = "TossApiError";
this.code = code;
this.requestId = requestId || null;
this.httpStatus = httpStatus;
this.data = redactDeep(data ?? null, secrets);
}
}
class TossCredentialsError extends Error {
constructor(message) {
super(message);
this.name = "TossCredentialsError";
}
}
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function redact(text, secrets = []) {
let out = String(text);
for (const secret of secrets) {
if (secret) {
out = out.split(String(secret)).join("[REDACTED]");
}
}
return out;
}
function redactDeep(value, secrets = []) {
if (value === undefined || value === null) {
return value;
}
try {
return JSON.parse(redact(JSON.stringify(value), secrets));
} catch {
return value;
}
}
function resolveConfig(options = {}) {
const env = options.env || process.env;
const baseUrl = String(
options.baseUrl ?? env.TOSSINVEST_API_BASE_URL ?? OFFICIAL_BASE_URL
).replace(/\/+$/u, "");
return {
clientId: options.clientId ?? env.TOSSINVEST_CLIENT_ID,
clientSecret: options.clientSecret ?? env.TOSSINVEST_CLIENT_SECRET,
account: options.account ?? env.TOSSINVEST_ACCOUNT,
baseUrl,
fetchImpl: options.fetch || globalThis.fetch,
now: options.now || Date.now,
sleep: options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
maxRetries: Number.isInteger(options.maxRetries) ? options.maxRetries : DEFAULT_MAX_RETRIES,
backoffBaseMs: Number.isFinite(options.backoffBaseMs) ? options.backoffBaseMs : DEFAULT_BACKOFF_BASE_MS,
jitter: typeof options.jitter === "function" ? options.jitter : Math.random
};
}
function collectSecrets(cfg, token) {
return [cfg && cfg.clientSecret, token].filter(Boolean);
}
function assertClientCredentials(cfg) {
if (isBlank(cfg.clientId) || isBlank(cfg.clientSecret)) {
throw new TossCredentialsError(
"Toss official API credentials are missing. Set TOSSINVEST_CLIENT_ID and TOSSINVEST_CLIENT_SECRET (or pass clientId/clientSecret)."
);
}
}
function assertFetch(cfg) {
if (typeof cfg.fetchImpl !== "function") {
throw new Error("A fetch implementation is required (Node 18+ global fetch or options.fetch).");
}
}
function tokenCacheKey(clientId, baseUrl) {
return `${clientId}::${baseUrl}`;
}
function clearTokenCache() {
tokenCache.clear();
}
async function readJson(response) {
if (response && typeof response.json === "function") {
try {
return await response.json();
} catch {
// fall through to text parsing
}
}
if (response && typeof response.text === "function") {
try {
const text = await response.text();
return text ? JSON.parse(text) : null;
} catch {
return null;
}
}
return null;
}
function headerValue(response, name) {
const headers = response && response.headers;
if (headers && typeof headers.get === "function") {
return headers.get(name);
}
return null;
}
function readRateLimit(response) {
const toNumber = (value) => (value === null || value === undefined || value === "" ? null : Number(value));
return {
limit: toNumber(headerValue(response, "x-ratelimit-limit")),
remaining: toNumber(headerValue(response, "x-ratelimit-remaining")),
reset: toNumber(headerValue(response, "x-ratelimit-reset"))
};
}
function buildUrl(baseUrl, path, query) {
const url = new URL(`${baseUrl}${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null) {
continue;
}
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function applyPathParams(path, params) {
if (!params) {
return path;
}
return path.replace(/\{(\w+)\}/gu, (_match, key) => {
const value = params[key];
if (isBlank(value)) {
throw new Error(`Missing path parameter: ${key}`);
}
return encodeURIComponent(String(value));
});
}
function normalizeSymbols(symbols) {
const list = Array.isArray(symbols) ? symbols : String(symbols ?? "").split(",");
const cleaned = list.map((symbol) => String(symbol).trim()).filter(Boolean);
if (cleaned.length === 0) {
throw new Error("symbols is required (one or more).");
}
return cleaned.join(",");
}
function requireSymbol(symbol) {
const value = String(symbol ?? "").trim();
if (!value) {
throw new Error("symbol is required.");
}
return value;
}
function buildAuthHeaders(token) {
if (isBlank(token)) {
throw new TossCredentialsError("An access token is required to build authorization headers.");
}
return { Authorization: `Bearer ${token}` };
}
function buildAccountHeaders(token, account) {
const headers = buildAuthHeaders(token);
if (isBlank(account)) {
throw new TossCredentialsError(
"X-Tossinvest-Account is required for account, asset, and order APIs. Set TOSSINVEST_ACCOUNT or pass options.account."
);
}
headers["X-Tossinvest-Account"] = String(account);
return headers;
}
async function issueAccessToken(options = {}) {
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
assertFetch(cfg);
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: cfg.clientId,
client_secret: cfg.clientSecret
});
const response = await cfg.fetchImpl(`${cfg.baseUrl}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json"
},
body: body.toString()
});
const payload = await readJson(response);
if (!response.ok) {
// The token endpoint uses the OAuth2 standard error shape, not the BFF envelope.
throw new TossApiError(
{
code: (payload && payload.error) || `http-${response.status}`,
message: (payload && payload.error_description) || "OAuth2 token request failed.",
requestId: headerValue(response, "x-request-id"),
httpStatus: response.status,
data: null
},
collectSecrets(cfg)
);
}
return {
accessToken: payload && payload.access_token,
tokenType: payload && payload.token_type,
expiresIn: payload && payload.expires_in,
raw: payload
};
}
async function getAccessToken(options = {}) {
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
const key = tokenCacheKey(cfg.clientId, cfg.baseUrl);
if (options.forceRefresh !== true) {
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > cfg.now()) {
return cached.accessToken;
}
}
const token = await issueAccessToken(options);
if (isBlank(token.accessToken)) {
throw new TossApiError(
{ code: "invalid-token-response", message: "Token endpoint did not return an access_token.", httpStatus: 200 },
collectSecrets(cfg)
);
}
const expiresInSeconds = Number(token.expiresIn);
const ttlMs = Number.isFinite(expiresInSeconds) ? expiresInSeconds * 1000 : 0;
tokenCache.set(key, {
accessToken: token.accessToken,
expiresAt: cfg.now() + ttlMs - TOKEN_EXPIRY_SKEW_MS
});
return token.accessToken;
}
function buildApiError(response, payload, secrets) {
const envelope = payload && payload.error;
const code = (envelope && envelope.code) || `http-${response.status}`;
const message =
(envelope && envelope.message) || `Toss official API request failed with status ${response.status}.`;
const requestId = (envelope && envelope.requestId) || headerValue(response, "x-request-id");
const data = (envelope && envelope.data) || null;
return new TossApiError({ code, message, requestId, httpStatus: response.status, data }, secrets);
}
function computeRetryDelayMs(response, cfg, attempt) {
const retryAfter = Number(headerValue(response, "retry-after"));
if (Number.isFinite(retryAfter) && retryAfter >= 0) {
return retryAfter * 1000;
}
const reset = Number(headerValue(response, "x-ratelimit-reset"));
if (Number.isFinite(reset) && reset >= 0) {
return reset * 1000;
}
const base = cfg.backoffBaseMs * 2 ** attempt;
return base + Math.floor(cfg.jitter() * cfg.backoffBaseMs);
}
async function tossApiRequest(endpointKey, requestOptions = {}, options = {}) {
const endpoint = ENDPOINTS[endpointKey];
if (!endpoint) {
throw new Error(`Unknown Toss official endpoint: ${endpointKey}`);
}
const cfg = resolveConfig(options);
assertClientCredentials(cfg);
assertFetch(cfg);
// Enforce the account-header requirement locally before any network call.
if (endpoint.requiresAccount && isBlank(cfg.account)) {
throw new TossCredentialsError(
`${endpointKey} requires the X-Tossinvest-Account header. Set TOSSINVEST_ACCOUNT or pass options.account.`
);
}
const path = applyPathParams(endpoint.path, requestOptions.pathParams);
const url = buildUrl(cfg.baseUrl, path, requestOptions.query);
let attempt = 0;
let tokenRetried = false;
for (;;) {
const token = await getAccessToken(options);
const headers = endpoint.requiresAccount
? buildAccountHeaders(token, cfg.account)
: buildAuthHeaders(token);
headers.Accept = "application/json";
const response = await cfg.fetchImpl(url, { method: "GET", headers });
const payload = await readJson(response);
if (response.ok) {
return {
data: payload,
rateLimit: readRateLimit(response),
requestId: headerValue(response, "x-request-id") || (payload && payload.error && payload.error.requestId) || null,
status: response.status
};
}
// Expired/invalid token: clear the cache and re-issue exactly once.
if (response.status === 401 && !tokenRetried) {
tokenRetried = true;
tokenCache.delete(tokenCacheKey(cfg.clientId, cfg.baseUrl));
continue;
}
// Rate limited: honor Retry-After / reset, then exponential backoff + jitter.
if (response.status === 429 && attempt < cfg.maxRetries) {
const delayMs = computeRetryDelayMs(response, cfg, attempt);
attempt += 1;
await cfg.sleep(delayMs);
continue;
}
throw buildApiError(response, payload, collectSecrets(cfg, token));
}
}
// --- Read-only helpers (1:1 with GET endpoints) ---
// Market data (bearer only)
function getOrderbook(symbol, options = {}) {
return tossApiRequest("getOrderbook", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getPrices(symbols, options = {}) {
return tossApiRequest("getPrices", { query: { symbols: normalizeSymbols(symbols) } }, options);
}
function getTrades(symbol, options = {}) {
return tossApiRequest("getTrades", { query: { symbol: requireSymbol(symbol), count: options.count } }, options);
}
function getPriceLimits(symbol, options = {}) {
return tossApiRequest("getPriceLimits", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getCandles(symbol, options = {}) {
if (isBlank(options.interval)) {
throw new Error("interval is required for getCandles ('1m' or '1d').");
}
return tossApiRequest(
"getCandles",
{
query: {
symbol: requireSymbol(symbol),
interval: options.interval,
count: options.count,
before: options.before,
adjusted: options.adjusted
}
},
options
);
}
// Stock info (bearer only)
function getStocks(symbols, options = {}) {
return tossApiRequest("getStocks", { query: { symbols: normalizeSymbols(symbols) } }, options);
}
function getStockWarnings(symbol, options = {}) {
return tossApiRequest("getStockWarnings", { pathParams: { symbol: requireSymbol(symbol) } }, options);
}
// Market info (bearer only)
function getExchangeRate(options = {}) {
return tossApiRequest(
"getExchangeRate",
{ query: { from: options.from, to: options.to, dateTime: options.dateTime } },
options
);
}
function getMarketCalendarKR(options = {}) {
return tossApiRequest("getMarketCalendarKR", { query: { date: options.date } }, options);
}
function getMarketCalendarUS(options = {}) {
return tossApiRequest("getMarketCalendarUS", { query: { date: options.date } }, options);
}
// Account / asset
function listOfficialAccounts(options = {}) {
return tossApiRequest("listOfficialAccounts", {}, options);
}
function getHoldings(options = {}) {
return tossApiRequest("getHoldings", { query: { symbol: options.symbol } }, options);
}
// Order history (read-only)
function listOpenOrders(options = {}) {
return tossApiRequest("listOpenOrders", { query: { status: options.status || "OPEN" } }, options);
}
function getOrderDetail(orderId, options = {}) {
const id = String(orderId ?? "").trim();
if (!id) {
throw new Error("orderId is required.");
}
return tossApiRequest("getOrderDetail", { pathParams: { orderId: id } }, options);
}
// Order info (read-only)
function getBuyingPower(options = {}) {
return tossApiRequest("getBuyingPower", { query: { currency: options.currency } }, options);
}
function getSellableQuantity(symbol, options = {}) {
return tossApiRequest("getSellableQuantity", { query: { symbol: requireSymbol(symbol) } }, options);
}
function getCommissions(options = {}) {
return tossApiRequest("getCommissions", {}, options);
}
module.exports = {
OFFICIAL_BASE_URL,
ENDPOINTS,
TossApiError,
TossCredentialsError,
resolveConfig,
clearTokenCache,
issueAccessToken,
getAccessToken,
buildAuthHeaders,
buildAccountHeaders,
tossApiRequest,
// read-only helpers
getOrderbook,
getPrices,
getTrades,
getPriceLimits,
getCandles,
getStocks,
getStockWarnings,
getExchangeRate,
getMarketCalendarKR,
getMarketCalendarUS,
listOfficialAccounts,
getHoldings,
listOpenOrders,
getOrderDetail,
getBuyingPower,
getSellableQuantity,
getCommissions
};

View file

@ -0,0 +1,394 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
ENDPOINTS,
TossApiError,
TossCredentialsError,
clearTokenCache,
issueAccessToken,
getAccessToken,
buildAuthHeaders,
buildAccountHeaders,
getOrderbook,
getPrices,
getTrades,
getPriceLimits,
getCandles,
getStocks,
getStockWarnings,
getExchangeRate,
getMarketCalendarKR,
getMarketCalendarUS,
listOfficialAccounts,
getHoldings,
listOpenOrders,
getOrderDetail,
getBuyingPower,
getSellableQuantity,
getCommissions
} = require("../src/official-client");
const CLIENT_ID = "c_test_id";
const CLIENT_SECRET = "s_super_secret_value";
const ACCESS_TOKEN = "eyJhbGciOiJ.access.token";
const BASE_URL = "https://openapi.tossinvest.com";
function jsonResponse({ status = 200, body = {}, headers = {} } = {}) {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(headers),
async json() {
return body;
},
async text() {
return JSON.stringify(body);
}
};
}
// Builds a fetch mock from an ordered queue of responses (or response factories).
// Records every call's url, method, headers, and body.
function makeFetch(queue) {
const calls = [];
const responses = Array.isArray(queue) ? [...queue] : [queue];
const fetchImpl = async (url, init = {}) => {
calls.push({ url, method: init.method || "GET", headers: init.headers || {}, body: init.body });
if (responses.length === 0) {
throw new Error(`Unexpected fetch call (no queued response) for ${url}`);
}
const next = responses.shift();
return typeof next === "function" ? next() : next;
};
fetchImpl.calls = calls;
return fetchImpl;
}
const tokenOk = () =>
jsonResponse({ body: { access_token: ACCESS_TOKEN, token_type: "Bearer", expires_in: 86400 } });
function baseOptions(extra = {}) {
return {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
baseUrl: BASE_URL,
env: {},
now: () => 1_000_000,
sleep: async () => {},
...extra
};
}
test.beforeEach(() => {
clearTokenCache();
});
test("issueAccessToken posts client_credentials form body to /oauth2/token", async () => {
const fetchImpl = makeFetch([tokenOk()]);
const result = await issueAccessToken(baseOptions({ fetch: fetchImpl }));
assert.equal(result.accessToken, ACCESS_TOKEN);
assert.equal(result.tokenType, "Bearer");
assert.equal(result.expiresIn, 86400);
const call = fetchImpl.calls[0];
assert.equal(call.url, `${BASE_URL}/oauth2/token`);
assert.equal(call.method, "POST");
assert.equal(call.headers["Content-Type"], "application/x-www-form-urlencoded");
const params = new URLSearchParams(call.body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), CLIENT_ID);
assert.equal(params.get("client_secret"), CLIENT_SECRET);
});
test("getAccessToken caches the token and reuses it across calls", async () => {
const fetchImpl = makeFetch([tokenOk(), tokenOk()]);
const opts = baseOptions({ fetch: fetchImpl });
const first = await getAccessToken(opts);
const second = await getAccessToken(opts);
assert.equal(first, ACCESS_TOKEN);
assert.equal(second, ACCESS_TOKEN);
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 1, "token endpoint should be hit exactly once");
});
test("getAccessToken refreshes after expiry", async () => {
const fetchImpl = makeFetch([
jsonResponse({ body: { access_token: "tok-1", token_type: "Bearer", expires_in: 100 } }),
jsonResponse({ body: { access_token: "tok-2", token_type: "Bearer", expires_in: 100 } })
]);
let clock = 1_000_000;
const opts = baseOptions({ fetch: fetchImpl, now: () => clock });
const first = await getAccessToken(opts);
assert.equal(first, "tok-1");
// Advance beyond expiry (100s ttl minus 60s skew => ~40s of validity).
clock += 200_000;
const second = await getAccessToken(opts);
assert.equal(second, "tok-2");
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 2);
});
test("market helpers send only the bearer header (no account header)", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: { price: "72000" } } })]);
const res = await getPrices(["005930", "AAPL"], baseOptions({ fetch: fetchImpl, account: "1" }));
assert.deepEqual(res.data, { result: { price: "72000" } });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/prices"));
assert.equal(apiCall.headers.Authorization, `Bearer ${ACCESS_TOKEN}`);
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined);
});
test("getPrices and getStocks comma-join multiple symbols per the OpenAPI symbols param", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ body: { result: [] } }),
jsonResponse({ body: { result: [] } })
]);
const opts = baseOptions({ fetch: fetchImpl });
await getPrices(["005930", "000660", "AAPL"], opts);
await getStocks(["005930", "AAPL"], opts);
const pricesCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/prices"));
const stocksCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/stocks"));
assert.equal(new URL(pricesCall.url).searchParams.get("symbols"), "005930,000660,AAPL");
assert.equal(new URL(stocksCall.url).searchParams.get("symbols"), "005930,AAPL");
});
test("account helpers send X-Tossinvest-Account header when account is configured", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: { holdings: [] } } })]);
const res = await getHoldings(baseOptions({ fetch: fetchImpl, account: "42" }));
assert.deepEqual(res.data, { result: { holdings: [] } });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/holdings"));
assert.equal(apiCall.headers["X-Tossinvest-Account"], "42");
assert.equal(apiCall.headers.Authorization, `Bearer ${ACCESS_TOKEN}`);
});
test("account-required helper throws TossCredentialsError before any network call when account is missing", async () => {
const fetchImpl = makeFetch([]);
await assert.rejects(
() => getHoldings(baseOptions({ fetch: fetchImpl })),
(error) => error instanceof TossCredentialsError && /X-Tossinvest-Account/.test(error.message)
);
assert.equal(fetchImpl.calls.length, 0, "no fetch should occur for a missing account header");
});
test("listOfficialAccounts is bearer-only and does not require an account header", async () => {
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: [{ accountSeq: 1 }] } })]);
const res = await listOfficialAccounts(baseOptions({ fetch: fetchImpl }));
assert.deepEqual(res.data, { result: [{ accountSeq: 1 }] });
const apiCall = fetchImpl.calls.find((c) => c.url.includes("/api/v1/accounts"));
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined);
assert.equal(ENDPOINTS.listOfficialAccounts.requiresAccount, false);
});
test("error envelope is parsed into TossApiError with code, message, requestId, httpStatus", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 404,
body: { error: { requestId: "REQ-1", code: "stock-not-found", message: "no such stock" } }
})
]);
await assert.rejects(
() => getStockWarnings("ZZZZ", baseOptions({ fetch: fetchImpl })),
(error) => {
assert.ok(error instanceof TossApiError);
assert.equal(error.code, "stock-not-found");
assert.equal(error.requestId, "REQ-1");
assert.equal(error.httpStatus, 404);
assert.match(error.message, /stock-not-found/);
return true;
}
);
});
test("requestId falls back to the X-Request-Id header when absent from the body", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 500,
headers: { "X-Request-Id": "HDR-REQ-9" },
body: { error: { code: "internal-error", message: "boom" } }
})
]);
await assert.rejects(
() => getCommissions(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => error instanceof TossApiError && error.requestId === "HDR-REQ-9"
);
});
test("thrown errors never expose the client_secret or the access token", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 400,
body: {
error: {
requestId: "REQ-2",
code: "invalid-request",
message: `leaked ${CLIENT_SECRET} and ${ACCESS_TOKEN}`,
data: { secret: CLIENT_SECRET, token: ACCESS_TOKEN }
}
}
})
]);
await assert.rejects(
() => getBuyingPower(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => {
const serialized = `${error.message} ${JSON.stringify(error.data)}`;
assert.ok(!serialized.includes(CLIENT_SECRET), "client_secret must be redacted");
assert.ok(!serialized.includes(ACCESS_TOKEN), "access token must be redacted");
assert.match(serialized, /\[REDACTED\]/);
return true;
}
);
});
test("a 401 re-issues the token exactly once, then throws on a second 401", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R1" } } }),
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R2" } } })
]);
await assert.rejects(
() => getHoldings(baseOptions({ fetch: fetchImpl, account: "1" })),
(error) => error instanceof TossApiError && error.code === "expired-token"
);
const tokenCalls = fetchImpl.calls.filter((c) => c.url.endsWith("/oauth2/token"));
assert.equal(tokenCalls.length, 2, "token should be re-issued exactly once after the first 401");
});
test("a 401 followed by success retries with a fresh token", async () => {
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 401, body: { error: { code: "expired-token", message: "expired", requestId: "R1" } } }),
tokenOk(),
jsonResponse({ body: { result: { ok: true } } })
]);
const res = await getHoldings(baseOptions({ fetch: fetchImpl, account: "1" }));
assert.deepEqual(res.data, { result: { ok: true } });
});
test("429 waits Retry-After then retries and succeeds", async () => {
const slept = [];
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({
status: 429,
headers: { "Retry-After": "2", "X-RateLimit-Remaining": "0" },
body: { error: { code: "rate-limit-exceeded", message: "slow down" } }
}),
jsonResponse({ body: { result: { price: "1" } }, headers: { "X-RateLimit-Limit": "10", "X-RateLimit-Remaining": "9" } })
]);
const res = await getPrices(
"005930",
baseOptions({ fetch: fetchImpl, account: "1", sleep: async (ms) => slept.push(ms) })
);
assert.deepEqual(res.data, { result: { price: "1" } });
assert.equal(res.rateLimit.limit, 10);
assert.equal(res.rateLimit.remaining, 9);
assert.deepEqual(slept, [2000], "should wait Retry-After seconds (in ms)");
});
test("429 beyond maxRetries throws a TossApiError", async () => {
const slept = [];
const fetchImpl = makeFetch([
tokenOk(),
jsonResponse({ status: 429, headers: { "Retry-After": "1" }, body: { error: { code: "rate-limit-exceeded", message: "slow" } } }),
jsonResponse({ status: 429, headers: { "Retry-After": "1" }, body: { error: { code: "rate-limit-exceeded", message: "slow" } } })
]);
await assert.rejects(
() =>
getPrices(
"005930",
baseOptions({ fetch: fetchImpl, account: "1", maxRetries: 1, sleep: async (ms) => slept.push(ms) })
),
(error) => error instanceof TossApiError && error.code === "rate-limit-exceeded" && error.httpStatus === 429
);
assert.equal(slept.length, 1, "should retry exactly maxRetries times before throwing");
});
test("missing client credentials throws TossCredentialsError without echoing secrets", async () => {
const fetchImpl = makeFetch([]);
await assert.rejects(
() => getPrices("005930", { fetch: fetchImpl, env: {} }),
(error) =>
error instanceof TossCredentialsError &&
/TOSSINVEST_CLIENT_ID/.test(error.message) &&
!error.message.includes(CLIENT_SECRET)
);
assert.equal(fetchImpl.calls.length, 0);
});
test("buildAuthHeaders and buildAccountHeaders construct the expected header sets", () => {
assert.deepEqual(buildAuthHeaders("abc"), { Authorization: "Bearer abc" });
assert.deepEqual(buildAccountHeaders("abc", 7), {
Authorization: "Bearer abc",
"X-Tossinvest-Account": "7"
});
assert.throws(() => buildAccountHeaders("abc", ""), TossCredentialsError);
assert.throws(() => buildAuthHeaders(""), TossCredentialsError);
});
test("each read-only helper builds the correct path, query, headers, and path params", async () => {
const opts = (fetchImpl) => baseOptions({ fetch: fetchImpl, account: "5" });
const cases = [
{ run: (o) => getOrderbook("005930", o), path: "/api/v1/orderbook", query: { symbol: "005930" }, account: false },
{ run: (o) => getTrades("005930", { ...o, count: 30 }), path: "/api/v1/trades", query: { symbol: "005930", count: "30" }, account: false },
{ run: (o) => getPriceLimits("005930", o), path: "/api/v1/price-limits", query: { symbol: "005930" }, account: false },
{ run: (o) => getCandles("005930", { ...o, interval: "1d", count: 50 }), path: "/api/v1/candles", query: { symbol: "005930", interval: "1d", count: "50" }, account: false },
{ run: (o) => getStockWarnings("005930", o), path: "/api/v1/stocks/005930/warnings", query: {}, account: false },
{ run: (o) => getExchangeRate({ ...o, from: "USD", to: "KRW" }), path: "/api/v1/exchange-rate", query: { from: "USD", to: "KRW" }, account: false },
{ run: (o) => getMarketCalendarKR({ ...o, date: "2026-06-09" }), path: "/api/v1/market-calendar/KR", query: { date: "2026-06-09" }, account: false },
{ run: (o) => getMarketCalendarUS(o), path: "/api/v1/market-calendar/US", query: {}, account: false },
{ run: (o) => listOpenOrders(o), path: "/api/v1/orders", query: { status: "OPEN" }, account: true },
{ run: (o) => getOrderDetail("ORD-1", o), path: "/api/v1/orders/ORD-1", query: {}, account: true },
{ run: (o) => getBuyingPower({ ...o, currency: "KRW" }), path: "/api/v1/buying-power", query: { currency: "KRW" }, account: true },
{ run: (o) => getSellableQuantity("005930", o), path: "/api/v1/sellable-quantity", query: { symbol: "005930" }, account: true }
];
for (const tc of cases) {
clearTokenCache();
const fetchImpl = makeFetch([tokenOk(), jsonResponse({ body: { result: {} } })]);
await tc.run(opts(fetchImpl));
const apiCall = fetchImpl.calls.find((c) => !c.url.endsWith("/oauth2/token"));
const parsed = new URL(apiCall.url);
assert.equal(parsed.pathname, tc.path, `path for ${tc.path}`);
for (const [k, v] of Object.entries(tc.query)) {
assert.equal(parsed.searchParams.get(k), v, `query ${k} for ${tc.path}`);
}
if (tc.account) {
assert.equal(apiCall.headers["X-Tossinvest-Account"], "5", `account header for ${tc.path}`);
} else {
assert.equal(apiCall.headers["X-Tossinvest-Account"], undefined, `no account header for ${tc.path}`);
}
}
});
test("module exposes no order mutation helpers (read-only safety contract)", () => {
const mod = require("../src/official-client");
assert.equal(mod.placeOrder, undefined);
assert.equal(mod.createOrder, undefined);
assert.equal(mod.modifyOrder, undefined);
assert.equal(mod.cancelOrder, undefined);
});

View file

@ -386,6 +386,25 @@ test("lck-analytics docs and skill credit the original author and reference repo
assert.match(sources, /https:\/\/github\.com\/jerjangmin\/share\/tree\/main\/SKILL\/lck-analytics/);
});
test("repository docs advertise the korean-humanizer skill and credit im-not-ai and happy-nut", () => {
const readme = read("README.md");
const skill = read(path.join("korean-humanizer", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "korean-humanizer.md"));
const taxonomy = read(path.join("korean-humanizer", "references", "ai-tell-taxonomy.md"));
assert.match(readme, /\| 한국어 AI 윤문 \| `korean-humanizer` \|/);
assert.match(readme, /\[한국어 AI 윤문 가이드\]\(docs\/features\/korean-humanizer\.md\)/);
for (const doc of [skill, featureDoc, taxonomy]) {
assert.match(doc, /https:\/\/github\.com\/epoko77-ai\/im-not-ai/);
}
for (const doc of [skill, featureDoc]) {
assert.match(doc, /happy-nut/);
}
assert.match(skill, /S1|S2|S3/);
assert.match(skill, /references\/ai-tell-taxonomy\.md/);
});
test("repository docs advertise the korean-spell-check skill and usage constraints", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
@ -1832,7 +1851,7 @@ test("repository docs advertise the hipass-receipt skill across the documented s
assert.match(sources, /https:\/\/www\.hipass\.co\.kr\/html\/guide\/siteguide_6\.jsp/);
});
test("toss-securities skill documents the tossctl install, auth, and read-only workflow", () => {
test("toss-securities skill documents the official Open API and tossctl fallback workflow", () => {
const skillPath = path.join(repoRoot, "toss-securities", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected toss-securities/SKILL.md to exist");
@ -1843,6 +1862,12 @@ test("toss-securities skill documents the tossctl install, auth, and read-only w
assert.match(skill, /^name: toss-securities$/m);
for (const doc of [skill, featureDoc]) {
// Official Open API path (primary).
assert.match(doc, /openapi\.tossinvest\.com|developers\.tossinvest\.com/);
assert.match(doc, /TOSSINVEST_CLIENT_ID/);
assert.match(doc, /X-Tossinvest-Account/);
assert.match(doc, /\/oauth2\/token/);
// tossctl fallback path (retained).
assert.match(doc, /tossctl/);
assert.match(doc, /JungHoonGhae\/tossinvest-cli/);
assert.match(doc, /auth login/);
@ -1881,9 +1906,10 @@ test("hipass-receipt skill documents the logged-in browser session contract", ()
assert.match(packageReadme, /playwright-core/);
});
test("toss-securities package exposes safe read-only tossctl helpers", () => {
test("toss-securities package exposes safe read-only official + tossctl helpers", () => {
const pkg = require(path.join(repoRoot, "packages", "toss-securities", "src", "index.js"));
// tossctl fallback wrapper (retained).
assert.equal(typeof pkg.buildReadOnlyCommand, "function");
assert.equal(typeof pkg.runReadOnlyCommand, "function");
assert.equal(typeof pkg.getAccountSummary, "function");
@ -1891,6 +1917,20 @@ test("toss-securities package exposes safe read-only tossctl helpers", () => {
assert.equal(typeof pkg.getQuote, "function");
assert.equal(typeof pkg.getQuoteBatch, "function");
assert.equal(typeof pkg.listWatchlist, "function");
// Official Open API client (primary).
assert.equal(typeof pkg.issueAccessToken, "function");
assert.equal(typeof pkg.getPrices, "function");
assert.equal(typeof pkg.getHoldings, "function");
assert.equal(typeof pkg.getBuyingPower, "function");
assert.equal(typeof pkg.listOfficialAccounts, "function");
assert.equal(typeof pkg.TossApiError, "function");
assert.equal(typeof pkg.TossCredentialsError, "function");
// Read-only safety contract: no order mutation helpers.
assert.equal(pkg.placeOrder, undefined);
assert.equal(pkg.modifyOrder, undefined);
assert.equal(pkg.cancelOrder, undefined);
});
test("hipass-receipt package exposes fixture-friendly query, parse, and session helpers", () => {
@ -1903,9 +1943,14 @@ test("hipass-receipt package exposes fixture-friendly query, parse, and session
assert.equal(typeof pkg.buildReceiptRequest, "function");
});
test("toss-securities package README stays aligned with the read-only tossctl wrapper contract", () => {
test("toss-securities package README stays aligned with the official-first read-only contract", () => {
const packageReadme = read(path.join("packages", "toss-securities", "README.md"));
// Official Open API path (primary).
assert.match(packageReadme, /official.*Open API|공식 Open API/i);
assert.match(packageReadme, /TOSSINVEST_CLIENT_ID/);
assert.match(packageReadme, /X-Tossinvest-Account/);
// tossctl fallback path (retained).
assert.match(packageReadme, /read-only tossctl wrapper/i);
assert.match(packageReadme, /brew tap JungHoonGhae\/tossinvest-cli/);
assert.match(packageReadme, /account summary/);
@ -1963,7 +2008,7 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
});
test("repository docs advertise the korean-law-search skill with mode-specific korean-law-mcp setup guidance", () => {
test("repository docs advertise the korean-law-search skill via k-skill-proxy", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
@ -1979,26 +2024,30 @@ test("repository docs advertise the korean-law-search skill with mode-specific k
assert.match(readme, /\[한국 법령 검색 가이드\]\(docs\/features\/korean-law-search\.md\)/);
assert.match(readme, /\| 한국 법령 검색 \| .* \| 불필요 \|/);
assert.match(install, /--skill korean-law-search/);
assert.match(install, /로컬 CLI\/MCP 경로는 `LAW_OC`/);
assert.match(install, /remote endpoint는 `LAW_OC` 없이 `url`만/);
assert.match(setup, /한국 법령 검색의 로컬 CLI\/MCP 경로용 `LAW_OC`/);
assert.match(setup, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
assert.match(featureDoc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
assert.match(featureDoc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
assert.match(setupSkill, /로컬 한국 법령 검색: `LAW_OC` \+ `korean-law-mcp`/);
assert.match(setupSkill, /remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록/);
assert.match(install, /k-skill-proxy\.nomadamas\.org/);
assert.match(install, /운영자만 proxy 서버에 `LAW_OC`/);
assert.match(setup, /한국 법령 검색은 기본 hosted proxy/);
assert.match(setup, /self-host proxy 운영자만 서버 환경변수 `LAW_OC`/);
assert.match(featureDoc, /\/v1\/korean-law\/search/);
assert.match(featureDoc, /\/v1\/korean-law\/detail/);
assert.match(setupSkill, /한국 법령 검색은 기본 hosted proxy/);
assert.match(setupSkill, /운영자만 서버 환경변수 `LAW_OC`/);
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /LAW_OC/);
assert.match(doc, /korean-law-mcp/);
assert.match(doc, /k-skill-proxy/);
}
assert.match(sources, /korean-law-mcp: https:\/\/github\.com\/chrisryugj\/korean-law-mcp/);
assert.match(sources, /beopmang: https:\/\/api\.beopmang\.org/);
assert.match(roadmap, /한국 법령 검색 스킬 출시/);
for (const doc of [readme, install, setup, security, setupSkill, sources, featureDoc]) {
assert.doesNotMatch(doc, /법망|beopmang/i);
assert.doesNotMatch(doc, /api\.beopmang\.org/);
}
});
test("korean-law-search skill keeps korean-law-mcp-first guidance while documenting the approved Beopmang fallback", () => {
test("korean-law-search skill is proxy-first and drops the Beopmang fallback", () => {
const skillPath = path.join(repoRoot, "korean-law-search", "SKILL.md");
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
@ -2015,30 +2064,24 @@ test("korean-law-search skill keeps korean-law-mcp-first guidance while document
const doneSection = doneSectionMatch[1];
for (const doc of [skill, featureDoc]) {
assert.match(doc, /korean-law-mcp.*먼저|먼저.*korean-law-mcp|항상 `korean-law-mcp`를 먼저 사용/u);
assert.match(doc, /npm install -g korean-law-mcp/);
assert.match(doc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
assert.match(doc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
assert.match(doc, /\/v1\/korean-law\/search/);
assert.match(doc, /\/v1\/korean-law\/detail/);
assert.match(doc, /target=law/);
assert.match(doc, /target=prec/);
assert.match(doc, /open\.law\.go\.kr/);
assert.match(doc, /search_law/);
assert.match(doc, /get_law_text/);
assert.match(doc, /search_precedents/);
assert.match(doc, /search_interpretations/);
assert.match(doc, /search_ordinance/);
assert.match(doc, /https:\/\/korean-law-mcp\.fly\.dev\/mcp/);
assert.match(doc, /법망|Beopmang/i);
assert.match(doc, /https:\/\/api\.beopmang\.org/);
assert.match(doc, /fallback/i);
assert.match(doc, /MCP/i);
assert.match(doc, /CLI/i);
assert.match(doc, /github\.com\/chrisryugj\/korean-law-mcp/);
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
assert.match(doc, /LAW_OC/);
assert.doesNotMatch(doc, /법망|beopmang/i);
assert.doesNotMatch(doc, /api\.beopmang\.org/);
assert.doesNotMatch(doc, /npm install -g korean-law-mcp/);
assert.doesNotMatch(doc, /packages\/korean-law-search/);
assert.doesNotMatch(doc, /python-packages\/korean-law-search/);
}
assert.match(doneSection, /search_interpretations/);
assert.match(doneSection, /search_ordinance/);
assert.match(doneSection, /법망|Beopmang/i);
assert.match(doneSection, /fallback/i);
assert.match(doneSection, /target=prec/);
assert.match(doneSection, /target=ordin/);
assert.doesNotMatch(
featureDoc,

272
scripts/srt_booking.py Normal file
View file

@ -0,0 +1,272 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import base64
import contextlib
import io
import importlib
import json
import os
import sys
from types import ModuleType
from typing import Protocol
from srt_seats import parse_cars, parse_seats, sort_cars_for_booking, sort_seats_for_booking
SRT_SEAT_ENDPOINT = "https://etk.srail.kr/hpg/hra/01/selectPassengerResearchList.do"
TRAIN_ID_PREFIX = "srt:v1:"
TRAIN_ID_FIELDS = (
"train_number",
"dep_date",
"dep_time",
"arr_date",
"arr_time",
"train_code",
"dep_station_code",
"arr_station_code",
"dep_station_run_order",
"arr_station_run_order",
)
ROOM_CODE = {"general": "1", "special": "2"}
ROOM_NAME = {"general": "일반실", "special": "특실"}
class SrtTrainLike(Protocol):
train_number: str
dep_date: str
dep_time: str
arr_date: str
arr_time: str
train_code: str
train_name: str
dep_station_code: str
dep_station_name: str
arr_station_code: str
arr_station_name: str
dep_station_run_order: str
arr_station_run_order: str
general_seat_state: str
special_seat_state: str
reserve_wait_possible_code: str
def general_seat_available(self) -> bool: ...
def special_seat_available(self) -> bool: ...
def reserve_standby_available(self) -> bool: ...
class ResponseLike(Protocol):
text: str
class SessionLike(Protocol):
def get(self, url: str, params: dict[str, str]) -> ResponseLike: ...
class SrtClientLike(Protocol):
_session: SessionLike
def search_train(
self,
dep: str,
arr: str,
date: str,
time: str,
time_limit: str | None = None,
available_only: bool = True,
) -> list[SrtTrainLike]: ...
def load_srt_module() -> ModuleType:
try:
return importlib.import_module("SRT")
except ModuleNotFoundError as exc:
raise SystemExit("scripts/srt_booking.py requires SRTrain: python3 -m pip install SRTrain")
def build_client(auto_login: bool = False) -> SrtClientLike:
srt_module = load_srt_module()
srt_id = os.environ.get("KSKILL_SRT_ID", "")
srt_pw = os.environ.get("KSKILL_SRT_PASSWORD", "")
return srt_module.SRT(srt_id, srt_pw, auto_login=auto_login)
def train_id_payload(train: SrtTrainLike) -> dict[str, str]:
return {field: getattr(train, field) for field in TRAIN_ID_FIELDS}
def build_train_id(train: SrtTrainLike) -> str:
raw = json.dumps(train_id_payload(train), ensure_ascii=False, separators=(",", ":")).encode()
encoded = base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return f"{TRAIN_ID_PREFIX}{encoded}"
def parse_train_id(train_id: str) -> dict[str, str]:
if not train_id.startswith(TRAIN_ID_PREFIX):
raise SystemExit("train_id must start with srt:v1:")
encoded = train_id.removeprefix(TRAIN_ID_PREFIX)
padded = encoded + ("=" * ((4 - len(encoded) % 4) % 4))
try:
payload = json.loads(base64.urlsafe_b64decode(padded.encode()).decode())
except (ValueError, json.JSONDecodeError, UnicodeDecodeError) as exc:
raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id") from exc
if not isinstance(payload, dict):
raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id")
if any(not isinstance(payload.get(field), str) or not payload[field] for field in TRAIN_ID_FIELDS):
raise SystemExit("train_id is invalid; rerun search and copy a fresh train_id")
return {field: payload[field] for field in TRAIN_ID_FIELDS}
def find_train_by_id(trains: list[SrtTrainLike], train_id: str) -> SrtTrainLike | None:
expected = parse_train_id(train_id)
return next((train for train in trains if train_id_payload(train) == expected), None)
def normalize_train(train: SrtTrainLike, index: int) -> dict[str, str | bool | int]:
return {
"index": index,
"train_id": build_train_id(train),
"train_no": train.train_number,
"train_type": train.train_name,
"dep_name": train.dep_station_name,
"dep_date": train.dep_date,
"dep_time": train.dep_time,
"arr_name": train.arr_station_name,
"arr_date": train.arr_date,
"arr_time": train.arr_time,
"has_general_seat": train.general_seat_available(),
"has_special_seat": train.special_seat_available(),
"has_waiting_list": train.reserve_standby_available(),
}
def seat_page_params(train: SrtTrainLike, room: str, car_no: int | None) -> dict[str, str]:
return {
"runDt1": train.dep_date,
"dptDt1": train.dep_date,
"dptTm1": train.dep_time,
"trnNo1": f"{int(train.train_number):05d}",
"trnGpCd1": "300",
"dptRsStnCd1": train.dep_station_code,
"arvRsStnCd1": train.arr_station_code,
"dptStnRunOrdr1": train.dep_station_run_order,
"arvStnRunOrdr1": train.arr_station_run_order,
"seatAttCd1": "015",
"psrmClCd1": ROOM_CODE[room],
"index1": "0",
"scarNo1": "" if car_no is None else f"{car_no:04d}",
"chtnDvCd": "1",
"jrnySqno": "001",
"mode": "1",
"psgNum": "1",
"pageId": "",
}
def fetch_seat_page(client: SrtClientLike, train: SrtTrainLike, room: str, car_no: int | None) -> str:
with contextlib.redirect_stdout(io.StringIO()):
response = client._session.get(SRT_SEAT_ENDPOINT, params=seat_page_params(train, room, car_no))
return response.text
def command_search(args: argparse.Namespace) -> None:
client = build_client(auto_login=False)
with contextlib.redirect_stdout(io.StringIO()):
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, args.available_only)
print_json({"count": len(trains[: args.limit]), "trains": [normalize_train(train, index) for index, train in enumerate(trains[: args.limit], 1)]})
def command_seats(args: argparse.Namespace) -> None:
client = build_client(auto_login=False)
with contextlib.redirect_stdout(io.StringIO()):
trains = client.search_train(args.dep, args.arr, args.date, args.time, args.time_limit, available_only=False)
train = find_train_by_id(trains, args.train_id)
if train is None:
raise SystemExit("train_id no longer matches any current search result; rerun search and choose a fresh train_id")
initial_html = fetch_seat_page(client, train, args.room, args.car_no)
cars = [car for car in parse_cars(initial_html) if car["room_class"] == ROOM_NAME[args.room]]
if args.car_no is not None:
cars = [car for car in cars if car["car_no"] == args.car_no]
else:
cars = [car for car in cars if car["available"]]
if not cars:
raise SystemExit(f"seat car data is unavailable for {args.room}; retry search or choose another train")
car_payloads: list[dict[str, object]] = []
for car in sort_cars_for_booking(cars, args.car_priority):
html = initial_html if args.car_no == car["car_no"] else fetch_seat_page(client, train, args.room, car["car_no"])
seats = parse_seats(html)
if args.seat:
seats = [seat for seat in seats if seat["seat"] == args.seat]
seats = sort_seats_for_booking(seats, args.seat_priority)
if args.available_only:
seats = [seat for seat in seats if seat["available"]]
available_seats = [seat for seat in seats if seat["available"]]
limited = seats[: args.limit]
payload = dict(car)
payload["available_seat_count"] = len(available_seats)
payload["available_seats"] = [seat["seat"] for seat in available_seats]
payload["shown_seat_count"] = len(limited)
payload["seats"] = limited
if args.seat:
payload["requested_seat"] = args.seat
payload["requested_seat_available"] = any(seat["available"] for seat in seats)
car_payloads.append(payload)
print_json({
"train": normalize_train(train, 1),
"room": args.room,
"available_only": args.available_only,
"car_priority": args.car_priority,
"seat_priority": args.seat_priority,
"cars": car_payloads,
})
def print_json(payload: dict[str, object]) -> None:
print(json.dumps(payload, ensure_ascii=False, indent=2))
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="SRT search and seat lookup helper for k-skill")
subparsers = parser.add_subparsers(dest="command", required=True)
search = subparsers.add_parser("search", help="SRT 열차를 조회합니다")
add_trip_args(search)
search.add_argument("--time-limit", default=None, help="조회 종료 시각 HHMMSS")
search.add_argument("--available-only", action="store_true", default=False, help="예약 가능한 열차만 출력")
search.add_argument("--limit", type=int, default=5, help="출력할 최대 열차 수")
search.set_defaults(func=command_search)
seats = subparsers.add_parser("seats", help="SRT 호차별 좌석번호를 조회합니다")
add_trip_args(seats)
seats.add_argument("--train-id", required=True, help="search 결과에서 복사한 stable train_id")
seats.add_argument("--time-limit", default=None, help="조회 종료 시각 HHMMSS")
seats.add_argument("--room", choices=sorted(ROOM_CODE), default="general")
seats.add_argument("--car-no", type=int, default=None, help="특정 호차만 조회")
seats.add_argument("--seat", default=None, help="특정 좌석번호만 조회, 예: 6C")
seats.add_argument("--available-only", action="store_true", help="빈 좌석만 출력")
seats.add_argument("--car-priority", choices=("center", "low", "high"), default="center")
seats.add_argument("--seat-priority", choices=("forward-window", "window-forward", "row-low"), default="forward-window")
seats.add_argument("--limit", type=int, default=100, help="호차별 출력할 최대 좌석 수")
seats.set_defaults(func=command_seats)
return parser
def add_trip_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("dep", help="출발역")
parser.add_argument("arr", help="도착역")
parser.add_argument("date", help="출발일 YYYYMMDD")
parser.add_argument("time", help="희망 시작 시각 HHMMSS")
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
args.func(args)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View file

@ -0,0 +1,128 @@
from __future__ import annotations
SEAT_HTML = "\n".join([
'<li class="scar-01 off"><strong>일반실<br />1호차</strong></li>',
'<li class="scar-04 on"><a href="#none" onclick="selectScarInfo(\'0004\'); return false;"><strong>일반실<br />4호차</strong></a></li>',
'<li class="scar-05"><a href="#none" onclick="selectScarInfo(\'0005\'); return false;"><strong>일반실<br />5호차</strong></a></li>',
'<li class="scar-03 off"><strong>특실<br />3호차</strong></li>',
'<a href="#none" onclick="selectSeatInfo(this, \'23\', \'6C\'); return false;">6C<strong><em>(정방향, 내측)</em></strong></a>',
'<a href="#none" onclick="selectSeatInfo(this, \'11\', \'3A\'); return false;">3A<strong><em>(역방향, 창측)</em></strong></a>',
"<span>5C<strong><em>(정방향, 내측, 선택불가)</em></strong></span>",
])
SPECIAL_SEAT_HTML = "\n".join([
'<li class="scar-03 on"><a href="#none" onclick="selectScarInfo(\'0003\'); return false;"><strong>특실<br />3호차</strong></a></li>',
'<li class="scar-05 off"><strong>일반실<br />5호차</strong></li>',
'<a href="#none" onclick="selectSeatInfo(this, \'31\', \'1A\'); return false;">1A<strong><em>(정방향, 1인석)</em></strong></a>',
"<span>2C<strong><em>(역방향, 내측, 선택불가)</em></strong></span>",
])
class FakeTrain:
train_number = "313"
dep_date = "20260610"
dep_time = "080000"
arr_date = "20260610"
arr_time = "103400"
train_code = "17"
train_name = "SRT"
dep_station_code = "0551"
dep_station_name = "수서"
arr_station_code = "0020"
arr_station_name = "부산"
dep_station_run_order = "000001"
arr_station_run_order = "000007"
general_seat_state = "예약가능"
special_seat_state = "매진"
reserve_wait_possible_code = "-2"
def general_seat_available(self) -> bool:
return True
def special_seat_available(self) -> bool:
return False
def reserve_standby_available(self) -> bool:
return False
class FakeResponse:
def __init__(self, text: str) -> None:
self.text = text
class FakeSession:
def __init__(self) -> None:
self.calls: list[dict[str, str]] = []
def get(self, _url: str, params: dict[str, str]) -> FakeResponse:
self.calls.append(params)
car = params["scarNo1"] or "0004"
return FakeResponse(SEAT_HTML.replace("scar-04 on", f"scar-{car[-2:]} on"))
class FakeClient:
def __init__(self, train: FakeTrain) -> None:
self.train = train
self._session = FakeSession()
def search_train(
self,
_dep: str,
_arr: str,
_date: str,
_time: str,
_time_limit: str | None = None,
available_only: bool = True,
) -> list[FakeTrain]:
return [self.train]
class NoisySession(FakeSession):
def get(self, _url: str, params: dict[str, str]) -> FakeResponse:
print("접속자가 많아 대기열에 들어갑니다.")
return super().get(_url, params)
class NoisyClient(FakeClient):
def __init__(self, train: FakeTrain) -> None:
self.train = train
self._session = NoisySession()
def search_train(
self,
_dep: str,
_arr: str,
_date: str,
_time: str,
_time_limit: str | None = None,
available_only: bool = True,
) -> list[FakeTrain]:
print("대기인원: 6명")
return [self.train]
class EmptyClient(FakeClient):
def search_train(
self,
_dep: str,
_arr: str,
_date: str,
_time: str,
_time_limit: str | None = None,
available_only: bool = True,
) -> list[FakeTrain]:
return []
class SpecialSession(FakeSession):
def get(self, _url: str, params: dict[str, str]) -> FakeResponse:
self.calls.append(params)
return FakeResponse(SPECIAL_SEAT_HTML)
class SpecialClient(FakeClient):
def __init__(self, train: FakeTrain) -> None:
self.train = train
self._session = SpecialSession()

156
scripts/srt_seats.py Normal file
View file

@ -0,0 +1,156 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from typing import TypedDict
class SrtCar(TypedDict):
car_no: int
car_no_raw: str
room_class: str
available: bool
current: bool
class SrtSeat(TypedDict):
seat: str
seat_no: str
available: bool
direction: str
position: str
notes: list[str]
CAR_RE = re.compile(
r'<li class="scar-(?P<car>\d+)(?P<class>[^"]*)">(?P<body>.*?)</li>',
re.DOTALL,
)
SEAT_LINK_RE = re.compile(
r"<a[^>]+selectSeatInfo\(this,\s*'(?P<seat_no>[^']+)',\s*'(?P<seat>[^']+)'\)[^>]*>"
r".*?<em>\((?P<detail>[^)]*)\)</em>",
re.DOTALL,
)
SEAT_SPAN_RE = re.compile(
r"<span>\s*(?P<seat>\d+[A-Z])\s*<strong><em>\((?P<detail>[^)]*)\)</em></strong></span>",
re.DOTALL,
)
TAG_RE = re.compile(r"<[^>]+>")
def strip_tags(value: str) -> str:
return TAG_RE.sub(" ", value).replace("\xa0", " ").strip()
def parse_detail(detail: str) -> tuple[str, str, list[str]]:
parts = [part.strip() for part in detail.split(",")]
direction = next((part for part in parts if part in {"정방향", "역방향"}), "unknown")
position = next((part for part in parts if part in {"창측", "내측", "1인석"}), "unknown")
notes = [part for part in parts if part not in {direction, position} and part]
return direction, position, notes
def parse_cars(html: str) -> list[SrtCar]:
cars: list[SrtCar] = []
for match in CAR_RE.finditer(html):
body = match.group("body")
text = strip_tags(body)
room_class = "특실" if "특실" in text else "일반실"
css_class = match.group("class")
has_link = "selectScarInfo" in body
cars.append(
{
"car_no": int(match.group("car")),
"car_no_raw": f"{int(match.group('car')):04d}",
"room_class": room_class,
"available": has_link and "off" not in css_class.split(),
"current": "on" in css_class.split(),
}
)
return cars
def parse_seats(html: str) -> list[SrtSeat]:
seats: list[SrtSeat] = []
seen: set[str] = set()
for match in SEAT_LINK_RE.finditer(html):
direction, position, notes = parse_detail(match.group("detail"))
seat = match.group("seat")
seen.add(seat)
seats.append(
{
"seat": seat,
"seat_no": match.group("seat_no"),
"available": True,
"direction": direction,
"position": position,
"notes": notes,
}
)
for match in SEAT_SPAN_RE.finditer(html):
seat = match.group("seat")
if seat in seen:
continue
direction, position, notes = parse_detail(match.group("detail"))
seats.append(
{
"seat": seat,
"seat_no": "",
"available": False,
"direction": direction,
"position": position,
"notes": notes,
}
)
return seats
def parse_seat_label(seat_label: str) -> tuple[int | None, str]:
match = re.match(r"^(\d+)([A-Z])$", seat_label)
if match is None:
return None, ""
return int(match.group(1)), match.group(2)
def car_center_priority(car: SrtCar, car_numbers: list[int]) -> tuple[float, int]:
if not car_numbers:
return (0.0, car["car_no"])
center = (min(car_numbers) + max(car_numbers)) / 2
return (abs(car["car_no"] - center), car["car_no"])
def sort_cars_for_booking(cars: list[SrtCar], priority: str = "center") -> list[SrtCar]:
match priority:
case "center":
car_numbers = [car["car_no"] for car in cars]
return sorted(cars, key=lambda car: car_center_priority(car, car_numbers))
case "low":
return sorted(cars, key=lambda car: car["car_no"])
case "high":
return sorted(cars, key=lambda car: car["car_no"], reverse=True)
case _:
raise ValueError(f"unsupported car priority: {priority}")
def seat_preference_key(seat: SrtSeat, priority: str = "forward-window") -> tuple[int, int, int, str]:
row, column = parse_seat_label(seat["seat"])
forward_rank = 0 if seat["direction"] == "정방향" else 1
window_rank = 0 if seat["position"] in {"창측", "1인석"} else 1
row_rank = 999 if row is None else row
match priority:
case "forward-window":
return (forward_rank, window_rank, row_rank, column)
case "window-forward":
return (window_rank, forward_rank, row_rank, column)
case "row-low":
return (row_rank, forward_rank, window_rank, column)
case _:
raise ValueError(f"unsupported seat priority: {priority}")
def sort_seats_for_booking(seats: list[SrtSeat], priority: str = "forward-window") -> list[SrtSeat]:
return sorted(seats, key=lambda seat: seat_preference_key(seat, priority))
sort_cars = sort_cars_for_booking
sort_seats = sort_seats_for_booking

209
scripts/test_srt_booking.py Normal file
View file

@ -0,0 +1,209 @@
from __future__ import annotations
import argparse
import io
import json
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
import srt_booking
from srt_booking_test_support import EmptyClient, FakeClient, FakeTrain, NoisyClient, SpecialClient
class SrtSeatTests(unittest.TestCase):
def test_command_seats_outputs_available_seats_by_booking_preference(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat="6C",
available_only=False,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
car = result["cars"][0]
self.assertEqual(car["car_no"], 4)
self.assertTrue(car["requested_seat_available"])
self.assertEqual(car["available_seats"], ["6C"])
self.assertEqual(client._session.calls[-1]["scarNo1"], "0004")
def test_command_seats_filters_unavailable_when_available_only(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
shown_seats = result["cars"][0]["seats"]
self.assertEqual([seat["seat"] for seat in shown_seats], ["6C", "3A"])
self.assertTrue(all(seat["available"] for seat in shown_seats))
def test_command_seats_returns_special_room_cars(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = SpecialClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="special",
car_no=3,
seat=None,
available_only=True,
car_priority="center",
seat_priority="window-forward",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["room"], "special")
self.assertEqual(result["cars"][0]["room_class"], "특실")
self.assertEqual(result["cars"][0]["available_seats"], ["1A"])
def test_command_seats_fails_when_train_id_is_stale(self) -> None:
train = FakeTrain()
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=srt_booking.build_train_id(train),
room="general",
car_no=4,
seat=None,
available_only=False,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
with patch.object(srt_booking, "build_client", return_value=EmptyClient(train)):
with self.assertRaises(SystemExit) as exc:
with redirect_stdout(io.StringIO()):
srt_booking.command_seats(args)
self.assertIn("train_id", str(exc.exception))
def test_command_seats_keeps_json_stdout_when_upstream_prints_queue_messages(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = NoisyClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=4,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
output = io.StringIO()
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(output):
srt_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual(result["cars"][0]["available_seats"], ["6C", "3A"])
def test_command_seats_explores_middle_cars_first(self) -> None:
train = FakeTrain()
train_id = srt_booking.build_train_id(train)
client = FakeClient(train)
args = argparse.Namespace(
dep="수서",
arr="부산",
date="20260610",
time="080000",
time_limit=None,
train_id=train_id,
room="general",
car_no=None,
seat=None,
available_only=True,
car_priority="center",
seat_priority="forward-window",
limit=10,
)
with patch.object(srt_booking, "build_client", return_value=client):
with redirect_stdout(io.StringIO()):
srt_booking.command_seats(args)
self.assertEqual([call["scarNo1"] for call in client._session.calls], ["", "0004", "0005"])
def test_build_parser_accepts_seats_filters(self) -> None:
args = srt_booking.build_parser().parse_args([
"seats",
"수서",
"부산",
"20260610",
"080000",
"--train-id",
"srt:v1:test",
"--car-no",
"5",
"--seat",
"11A",
"--seat-priority",
"window-forward",
])
self.assertEqual(args.car_no, 5)
self.assertEqual(args.seat, "11A")
self.assertEqual(args.seat_priority, "window-forward")
if __name__ == "__main__":
unittest.main()

82
scripts/test_srt_seats.py Normal file
View file

@ -0,0 +1,82 @@
from __future__ import annotations
import unittest
import srt_seats
SEAT_HTML = "\n".join([
'<li class="scar-01 off"><strong>일반실<br />1호차</strong></li>',
'<li class="scar-04 on"><a href="#none" onclick="selectScarInfo(\'0004\'); return false;"><strong>일반실<br />4호차</strong></a></li>',
'<li class="scar-05"><a href="#none" onclick="selectScarInfo(\'0005\'); return false;"><strong>일반실<br />5호차</strong></a></li>',
'<li class="scar-03 off"><strong>특실<br />3호차</strong></li>',
'<a href="#none" onclick="selectSeatInfo(this, \'23\', \'6C\'); return false;">6C<strong><em>(정방향, 내측)</em></strong></a>',
'<a href="#none" onclick="selectSeatInfo(this, \'11\', \'3A\'); return false;">3A<strong><em>(역방향, 창측)</em></strong></a>',
"<span>5C<strong><em>(정방향, 내측, 선택불가)</em></strong></span>",
])
MISSING_DETAIL_HTML = "\n".join([
'<a href="#none" onclick="selectSeatInfo(this, \'41\', \'9A\'); return false;">9A<strong><em>()</em></strong></a>',
])
class SrtSeatParserTests(unittest.TestCase):
def test_normalize_car_and_seat_maps_srt_html(self) -> None:
cars = srt_seats.parse_cars(SEAT_HTML)
seats = srt_seats.parse_seats(SEAT_HTML)
self.assertEqual([car["car_no"] for car in cars if car["available"]], [4, 5])
self.assertEqual(cars[1]["room_class"], "일반실")
self.assertTrue(cars[1]["current"])
self.assertEqual([seat["seat"] for seat in seats if seat["available"]], ["6C", "3A"])
self.assertEqual([seat["seat"] for seat in seats if not seat["available"]], ["5C"])
self.assertEqual(seats[0]["direction"], "정방향")
self.assertEqual(seats[0]["position"], "내측")
self.assertEqual(seats[2]["notes"], ["선택불가"])
def test_booking_priority_sorts_middle_cars_before_end_cars(self) -> None:
cars: list[srt_seats.SrtCar] = [
{"car_no": 1, "car_no_raw": "0001", "room_class": "일반실", "available": True, "current": False},
{"car_no": 8, "car_no_raw": "0008", "room_class": "일반실", "available": True, "current": False},
{"car_no": 2, "car_no_raw": "0002", "room_class": "일반실", "available": True, "current": False},
{"car_no": 7, "car_no_raw": "0007", "room_class": "일반실", "available": True, "current": False},
{"car_no": 3, "car_no_raw": "0003", "room_class": "일반실", "available": True, "current": False},
{"car_no": 6, "car_no_raw": "0006", "room_class": "일반실", "available": True, "current": False},
{"car_no": 4, "car_no_raw": "0004", "room_class": "일반실", "available": True, "current": False},
{"car_no": 5, "car_no_raw": "0005", "room_class": "일반실", "available": True, "current": False},
]
sorted_cars = srt_seats.sort_cars_for_booking(cars)
self.assertEqual([car["car_no"] for car in sorted_cars], [4, 5, 3, 6, 2, 7, 1, 8])
def test_booking_priority_sorts_forward_window_before_other_seats(self) -> None:
seats: list[srt_seats.SrtSeat] = [
{"seat": "3A", "seat_no": "11", "available": True, "direction": "역방향", "position": "창측", "notes": []},
{"seat": "6C", "seat_no": "23", "available": True, "direction": "정방향", "position": "내측", "notes": []},
{"seat": "2A", "seat_no": "7", "available": True, "direction": "정방향", "position": "창측", "notes": []},
]
sorted_seats = srt_seats.sort_seats_for_booking(seats, "forward-window")
self.assertEqual([seat["seat"] for seat in sorted_seats], ["2A", "6C", "3A"])
def test_booking_priority_treats_single_seat_as_window_preference(self) -> None:
seats: list[srt_seats.SrtSeat] = [
{"seat": "1C", "seat_no": "3", "available": True, "direction": "정방향", "position": "내측", "notes": []},
{"seat": "2A", "seat_no": "5", "available": True, "direction": "정방향", "position": "1인석", "notes": []},
]
sorted_seats = srt_seats.sort_seats_for_booking(seats, "window-forward")
self.assertEqual([seat["seat"] for seat in sorted_seats], ["2A", "1C"])
def test_parse_seat_page_marks_missing_detail_attributes_unknown(self) -> None:
seats = srt_seats.parse_seats(MISSING_DETAIL_HTML)
self.assertEqual(seats[0]["direction"], "unknown")
self.assertEqual(seats[0]["position"], "unknown")
if __name__ == "__main__":
unittest.main()

View file

@ -12,12 +12,15 @@ metadata:
## What this skill does
`SRTrain` 위에서 SRT 좌석을 조회하고, 조건이 맞으면 예약과 취소까지 진행한다.
`SRTrain` 위에 `scripts/srt_booking.py` helper 를 얹어 SRT 조회와 호차별 좌석번호 확인을 처리하고, 예약과 취소는 고정된 열차/예약을 다시 식별한 뒤 `SRTrain`으로 진행한다.
## When to use
- "수서에서 부산 가는 SRT 찾아줘"
- "내일 오전 SRT 빈자리 있으면 잡아줘"
- "SRT 5호차 빈 좌석 확인해줘"
- "SRT 11A 좌석이 비었는지 봐줘"
- "창측 좌석을 우선해서 SRT 빈자리 보여줘"
- "예약 내역 확인해줘"
- "이 SRT 예약 취소해줘"
@ -54,6 +57,7 @@ metadata:
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호: 일반실 / 특실
- 좌석 상세 조건: 객실 등급, 호차 번호, 좌석 번호, 빈 좌석만 보기, 탐색 우선순위
## Workflow
@ -73,19 +77,10 @@ python3 -m pip install SRTrain
### 2. Search first
먼저 조회해서 후보를 요약한다.
먼저 helper 로 조회해서 후보를 요약한다.
```bash
python3 - <<'PY'
import os
from SRT import SRT
srt = SRT(os.environ["KSKILL_SRT_ID"], os.environ["KSKILL_SRT_PASSWORD"])
trains = srt.search_train("수서", "부산", "20260328", "080000", time_limit="120000")
for idx, train in enumerate(trains[:5], start=1):
print(idx, train)
PY
python3 scripts/srt_booking.py search 수서 부산 20260328 080000 --time-limit 120000 --limit 5
```
### 3. Summarize options before side effects
@ -96,7 +91,38 @@ PY
- 일반실/특실 가능 여부
- 예상 운임
### 4. Reserve only after the train is fixed
### 4. Inspect detailed seats when the user asks for seat numbers
`search` 의 좌석 가능 여부는 열차 단위 플래그다. 사용자가 "남은 좌석 번호", "호차별 좌석", "특정 좌석", "창측/순방향 자리", "예약 전에 자리 확인"처럼 구체적인 좌석을 물으면 예약 전에 `seats` 를 호출한다.
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id>
```
특정 호차의 빈 좌석만 확인하려면 `--car-no``--available-only` 를 쓴다.
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --available-only
```
특정 좌석이 비었는지 확인하려면 `--seat` 를 붙인다.
```bash
python3 scripts/srt_booking.py seats 수서 부산 20260328 080000 --train-id <train_id> --car-no 5 --seat 11A
```
특정 호차를 지정하지 않으면 가운데 호차부터 탐색한다. `--car-priority center|low|high` 로 호차 탐색 순서를 바꾸고, `--seat-priority forward-window|window-forward|row-low` 로 좌석 정렬 우선순위를 바꾼다.
상세 좌석 응답을 보여줄 때는 아래를 우선 요약한다.
- 호차별 `available_seat_count`
- 남은 좌석 번호 (`available_seats`)
- 좌석별 `direction`, `position`
- 특정 좌석 요청이면 `requested_seat_available`
이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계에서만 진행한다.
### 5. Reserve only after the train is fixed
예약은 부작용이 있으므로 정확한 열차를 고른 뒤에만 진행한다.
@ -116,7 +142,7 @@ print(reservation)
PY
```
### 5. Inspect or cancel
### 6. Inspect or cancel
취소 전에는 대상 예약을 다시 식별한다.
@ -134,6 +160,7 @@ PY
## Done when
- 조회 요청이면 후보 열차가 정리되어 있다
- 좌석 상세 확인이면 호차별 남은 좌석번호나 특정 좌석 공석 여부가 정리되어 있다
- 예약 요청이면 예약 결과, 운임, 구입기한이 확인되어 있다
- 취소 요청이면 어떤 예약을 취소했는지 명확하다
@ -141,10 +168,12 @@ PY
- 로그인 오류: 계정 정보나 SRT site policy 변경 가능성 확인
- 매진: 다른 시간대나 좌석 타입으로 재조회
- 좌석선택 페이지 형식 변경: helper 파서 업데이트 필요
- 네트워크 오류: 짧게 재시도하되 aggressive polling은 피하기
## Notes
- 상세 좌석 확인은 SRT 웹 좌석선택 페이지를 조회 전용으로 읽는다
- `SRTrain`은 SRT 전용 라이브러리라서 스킬 의도가 더 선명하다
- 결제 완료까지는 자동화하지 않는다
- 자동 재시도 루프는 계정 보호 차원에서 짧고 보수적으로 유지한다

View file

@ -1,6 +1,6 @@
---
name: toss-securities
description: 토스증권 조회형 질문에 대해 tossinvest-cli의 tossctl을 설치/로그인한 뒤 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목을 안전한 read-only 흐름으로 조회한다.
description: 토스증권 조회형 질문을 공식 Open API(OAuth2)로 우선 처리하고, 공식 credentials가 없으면 tossinvest-cli의 tossctl을 fallback으로 써서 계좌, 보유주식, 시세/종목/시장정보, 주문조회를 안전한 read-only 흐름으로 조회한다.
license: MIT
metadata:
category: finance
@ -12,31 +12,79 @@ metadata:
## What this skill does
`JungHoonGhae/tossinvest-cli``tossctl` 을 이용해 토스증권 **조회 전용(read-only)** 흐름을 실행한다.
토스증권 **조회 전용(read-only)** 흐름을 실행한다. 두 경로가 있다.
- 계좌 목록 / 요약
- 포트폴리오 보유 종목 / 비중
- 단일 종목 / 다중 종목 시세
- 미체결 주문 / 월간 체결 내역
- 관심 종목
1. **공식 Open API (권장)** — 토스증권 공식 Open API(`https://openapi.tossinvest.com`)를 OAuth 2.0 Client Credentials 토큰으로 호출.
2. **tossctl fallback** — 공식 credentials가 없을 때 `JungHoonGhae/tossinvest-cli``tossctl` 을 사용.
조회 항목:
- 계좌 목록 / 보유 주식
- 시세(현재가/호가/체결/상하한가/캔들) / 종목 정보 / 매수 유의사항
- 환율 / 장 운영 캘린더(KR·US)
- 대기중 주문 조회 / 주문 상세 / 매수가능금액 / 판매가능수량 / 수수료
- (tossctl fallback) 계좌 요약, 포트폴리오 비중, 관심종목
## When to use
- "토스증권 계좌 요약 보여줘"
- "토스증권 TSLA 시세 확인해줘"
- "관심종목 목록 보여줘"
- "이번 달 체결 내역 조회해줘"
- "토스증권 삼성전자 현재가 확인해줘"
- "내 보유 주식 보여줘"
- "대기중 주문 조회해줘"
- "원달러 환율 알려줘"
## Prerequisites
## 1. Prefer the official Open API
- macOS + Homebrew
- `tossctl` 설치
- `tossctl auth login` 으로 브라우저 세션 확보
- Node.js 18+
### Prerequisites
## Workflow
- 토스증권 OpenAPI 콘솔에서 발급한 `client_id` / `client_secret`
- Node.js 18+ (global `fetch`)
### 0. Install `tossctl` first when missing
자격 증명은 사용자 환경변수로 두고 helper가 토스 서버로 **직접** 호출한다. 공유 프록시로 보내지 않는다.
| 환경변수 | 설명 |
|---|---|
| `TOSSINVEST_CLIENT_ID` | client id (필수) |
| `TOSSINVEST_CLIENT_SECRET` | client secret (필수) |
| `TOSSINVEST_ACCOUNT` | accountSeq. 계좌·자산·주문조회에 필요 (선택) |
| `TOSSINVEST_API_BASE_URL` | 기본 `https://openapi.tossinvest.com` (선택) |
### Workflow
helper는 내부적으로 `POST /oauth2/token` 으로 토큰을 발급(Client Credentials)받아 `Authorization: Bearer` 로 호출한다. 계좌·자산·주문조회 API는 `X-Tossinvest-Account` 헤더가 추가로 필요하다.
```js
const {
getPrices,
listOfficialAccounts,
getHoldings
} = require("toss-securities");
async function main() {
const prices = await getPrices(["005930", "AAPL"]);
const accounts = await listOfficialAccounts();
const accountSeq = accounts.data.result[0].accountSeq;
const holdings = await getHoldings({ account: accountSeq });
console.log(prices.data);
console.log(holdings.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
- `429``Retry-After`/`X-RateLimit-Reset` 만큼 대기 후 백오프 재시도한다.
- `401` 은 토큰을 1회 재발급해 재시도한다.
- `client_secret`/토큰은 에러 메시지에서 마스킹된다.
## 2. tossctl fallback
공식 credentials가 없으면 비공식 `tossctl` 을 fallback으로 쓴다.
### Install `tossctl` first when missing
```bash
brew tap JungHoonGhae/tossinvest-cli
@ -48,46 +96,17 @@ tossctl auth login
로그인 세션이 없으면 먼저 위 흐름을 끝낸다. 다른 비공식 크롤링이나 임의 HTTP 재구현으로 우회하지 않는다.
### 1. Prefer the read-only `tossctl` surface
지원하는 read-only 명령:
지원하는 기본 명령:
- `tossctl account list --output json`
- `tossctl account summary --output json`
- `tossctl portfolio positions --output json`
- `tossctl portfolio allocation --output json`
- `tossctl quote get TSLA --output json`
- `tossctl quote batch TSLA 005930 VOO --output json`
- `tossctl orders list --output json`
- `tossctl orders completed --market all --output json`
- `tossctl watchlist list --output json`
- `tossctl orders completed --market all --output json`
### 2. Use the local package wrapper when scripting helps
패키지 wrapper(`getAccountSummary`, `getPortfolioPositions`, `getQuote`, `listWatchlist` 등)도 그대로 쓸 수 있다.
```js
const {
getAccountSummary,
getQuote,
listWatchlist
} = require("toss-securities");
async function main() {
const summary = await getAccountSummary();
const quote = await getQuote("TSLA");
const watchlist = await listWatchlist();
console.log(summary.data);
console.log(quote.data);
console.log(watchlist.data);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
### 3. Answer conservatively
## Answer conservatively
- 계좌번호/민감정보는 꼭 필요한 범위만 노출한다.
- 사용자가 "오늘" 같은 상대 날짜를 말하면 절대 날짜로 풀어 답한다.
@ -95,12 +114,13 @@ main().catch((error) => {
## Done when
- `tossctl` 설치/로그인 상태가 확인되었다.
- 요청에 맞는 read-only 명령을 실행했다.
- 공식 API credentials(또는 tossctl 로그인) 상태가 확인되었다.
- 요청에 맞는 read-only 호출을 실행했다.
- 결과를 한국어로 짧게 정리했다.
## Failure modes
- `tossctl auth login` 전이면 계좌/포트폴리오 조회가 실패할 수 있다.
- upstream 웹 API 구조가 바뀌면 `tossctl` 자체 업데이트가 필요할 수 있다.
- 공식 API credentials(`TOSSINVEST_CLIENT_ID`/`SECRET`)가 없으면 `TossCredentialsError` 로 명확히 실패한다.
- 계좌·자산·주문조회 helper에 `X-Tossinvest-Account` 가 없으면 네트워크 호출 전에 실패한다.
- tossctl fallback은 `auth login` 전이면 계좌/포트폴리오 조회가 실패할 수 있다.
- 계좌/주문 정보는 민감하므로 출력 범위를 과도하게 넓히지 않는다.