Compare commits

...

23 commits

Author SHA1 Message Date
Jeffrey (Dongkyu) Kim
08533bd9eb
Merge pull request #325 from NomaDamas/dev
Merge dev into main
2026-06-21 17:39:44 +09:00
Jeffrey (Dongkyu) Kim
eacdfb882a
Merge pull request #323 from jwb0501/feature/talent-search-skills
잡코리아·사람인 인재검색 스킬 추가
2026-06-19 14:38:47 +09:00
Jeffrey (Dongkyu) Kim
b14f65361f Wire JobKorea talent helper into CI 2026-06-18 10:37:08 +09:00
wbjung
caa1f0fd0d Fix JobKorea fallback row parsing 2026-06-18 00:04:07 +09:00
wbjung
c619d3b7c7 Add recruiting talent search skills 2026-06-17 23:14:54 +09:00
Jeffrey (Dongkyu) Kim
e735abe8a4
Merge PR #322: replace KakaoTalk skill with katok
Feature/#320
2026-06-17 16:52:39 +09:00
Jeffrey (Dongkyu) Kim
c3f44eef14 feat(kakaotalk-mac): replace kakaocli workflow with katok
Closes #320

Plan: .omo/plans/issue-320-katok-skill.md
2026-06-16 13:44:56 +09:00
Jeffrey (Dongkyu) Kim
1f186af480
Merge pull request #319 from NomaDamas/dev
Release dev to main
2026-06-15 13:12:02 +09:00
Jeffrey (Dongkyu) Kim
5fd58facf4
Merge pull request #318 from NomaDamas/changeset-release/main
chore: version packages
2026-06-15 13:11:49 +09:00
Jeffrey (Dongkyu) Kim
e0d842435b Merge main into dev for PR 319 2026-06-14 18:11:45 +09:00
Jeffrey (Dongkyu) Kim
ece355b807 fix(korean-humanizer): quote frontmatter description
Quote the long Korean description so the `예:` segment is parsed as a scalar
instead of an invalid YAML mapping key. Also ignore local `.gjc/` runtime state.

Verified with scripts/validate-skills.sh and downstream benchmark eligibility
classification.
2026-06-13 00:21:13 +09:00
Jeffrey (Dongkyu) Kim
a633b001be
feat: add business due-diligence skills
Reviewed, resolved conflicts with latest dev, applied follow-up fixes, and verified with npm run ci.
2026-06-12 19:35:14 +09:00
Jeffrey (Dongkyu) Kim
c8bb7f9f35 Merge dev and address PR review fixes 2026-06-12 19:34:20 +09:00
github-actions[bot]
7586c0dea8 chore: version packages 2026-06-12 10:07:08 +00:00
Jeffrey (Dongkyu) Kim
66f12cb43d
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>
2026-06-12 19:06:18 +09:00
Jeffrey (Dongkyu) Kim
f485591ac2
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.
2026-06-12 18:07:03 +09:00
John
440cd697a7 feat: 사업자 실사 스킬군 — 단품 5종 + 복합 1종, proxy route 3개 추가 (#316)
사업자등록번호로 "이 사업자 실제 문제 없나"를 무료 공공 데이터로 교차 조회하는
스킬군을 기여한다. 점수·등급·"위험" 라벨 없이 사실+출처+조회시각만 병렬한다.

단품 스킬:
- national-pension-workplace  국민연금 가입 사업장 (proxy, 3046071)
- nts-tax-delinquency         국세 체납 명단공개 (무인증 직접)
- fsc-corporate-info          금융위 기업기본정보 (proxy, 15043184)
- g2b-sanctioned-supplier     조달청 부정당제재 (proxy, 15129466)
- localdata-business-status   지방행정 인허가 영업상태 208업종 (무인증 직접)

복합 스킬:
- biz-health-check  위 5종 + 기존 nts-business-registration을 한 번에 호출

proxy(packages/k-skill-proxy):
- keyed route 3개 추가 — 키는 서버의 DATA_GO_KR_API_KEY로만 주입(사용자 시크릿 없음)
- 연금 route는 basic+detail+monthly 3콜 오케스트레이션 + 월별중복 dedup
- server.test.js에 route 테스트 10건 추가 (정상/503 미설정/400/403 forbidden)

무인증 스킬은 stdlib(urllib)만 사용해 의존성 없이 직접 호출한다.
문서: docs/features ×6, README 표·링크, docs/sources.md 갱신, plugin.json 재생성.

활용신청(프록시 운영 서버 등록 필요): 3046071·15043184·15129466
(15081808 국세청 상태조회는 nts-business-registration용으로 이미 등록, 키 공유).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:46:30 +09:00
Jeffrey (Dongkyu) Kim
b6200892e3 Revert "docs(korean-law-search): document official precedent API evidence (#313)"
This reverts commit 5faec8bb2a.
2026-06-11 11:45:04 +09:00
Jeffrey (Dongkyu) Kim
79f6038328
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
2026-06-10 22:50:47 +09:00
Jeffrey (Dongkyu) Kim
5faec8bb2a
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
2026-06-10 22:50:34 +09:00
Jeffrey (Dongkyu) Kim
f5d37ddbee
Merge pull request #309 from NomaDamas/changeset-release/main
chore: version packages
2026-06-09 10:55:22 +09:00
github-actions[bot]
819be4897a chore: version packages 2026-06-06 03:08:25 +00:00
Jeffrey (Dongkyu) Kim
1efef285ba
Merge pull request #302 from NomaDamas/dev
Release: dev → main
2026-06-06 12:07:27 +09:00
68 changed files with 6149 additions and 2120 deletions

View file

@ -1,5 +0,0 @@
---
"k-skill-proxy": patch
---
Archive unsupported Naver Map and Blue Ribbon proxy support. The proxy no longer registers `/v1/naver-map/*` or `/v1/blue-ribbon/nearby`, and the unsupported skill/package code is preserved under `legacy/` for a future revival if operational blockers are resolved.

View file

@ -9,6 +9,7 @@
"repository": "https://github.com/NomaDamas/k-skill",
"license": "MIT",
"skills": [
"./biz-health-check",
"./bunjang-search",
"./catchtable-sniper",
"./cheap-gas-nearby",
@ -29,6 +30,8 @@
"./fine-dust-location",
"./flight-ticket-search",
"./foresttrip-vacancy",
"./fsc-corporate-info",
"./g2b-sanctioned-supplier",
"./gangnamunni-clinic-search",
"./geeknews-search",
"./gongsijiga-search",
@ -39,6 +42,7 @@
"./hwp",
"./intercity-bus-booking",
"./iros-registry-automation",
"./jobkorea-talent-search",
"./joseon-sillok-search",
"./k-dart",
"./k-schoollunch-menu",
@ -72,15 +76,18 @@
"./lh-notice-search",
"./library-book-search",
"./local-election-candidate-search",
"./localdata-business-status",
"./lotto-results",
"./market-kurly-search",
"./mfds-drug-safety",
"./mfds-food-safety",
"./myrealtrip-search",
"./national-pension-workplace",
"./naver-blog-research",
"./naver-news-search",
"./naver-shopping-search",
"./nts-business-registration",
"./nts-tax-delinquency",
"./ohou-today-deal",
"./olive-young-search",
"./parking-lot-search",
@ -88,6 +95,7 @@
"./real-estate-search",
"./rhwp-advanced",
"./rhwp-edit",
"./saramin-talent-search",
"./seoul-bike",
"./seoul-density",
"./seoul-subway-arrival",

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

1
.gitignore vendored
View file

@ -10,5 +10,6 @@ __pycache__/
dist/
.sisyphus/
.omo/
.gjc/
.agents/

View file

@ -27,7 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 카카오톡 Mac 아카이브 검색 | `kakaotalk-mac` | `katok`으로 macOS 카카오톡 로컬 아카이브를 동기화하고 keyword/BM25/semantic 검색 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
@ -42,6 +42,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호를 중심으로, 상호·지역을 함께 주면 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
| 국민연금 가입 사업장 조회 | `national-pension-workplace` | 사업장명으로 국민연금 가입자수·당월 고지금액·월별 추이 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md) |
| 국세 체납 명단공개 검색 | `nts-tax-delinquency` | 상호·법인명으로 국세청 고액·상습체납자 명단공개 대조(무인증 공개 검색) | 불필요 | [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md) |
| 금융위 기업기본정보 조회 | `fsc-corporate-info` | 법인명으로 대표자·설립일·업종 등 법인 개요 조회와 사업자번호 교차검증(공공데이터포털 API, 프록시 경유) | 불필요 | [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md) |
| 부정당제재업체 조회 | `g2b-sanctioned-supplier` | 사업자번호로 나라장터 부정당제재(조회시점 유효 제재) 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md) |
| 인허가 영업상태 조회 | `localdata-business-status` | 상호+시군구로 동네 사업장(208업종)의 영업/휴업/폐업·업력·주소 조회(LOCALDATA 무인증) | 불필요 | [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.md) |
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
| 지방선거 후보자 조회 | `local-election-candidate-search` | 중앙선거관리위원회 선거통계시스템 공개 통합검색으로 지방선거 후보자 이력·선거종류·정당·지역·득표 정보를 이름 기준으로 조회 | 불필요 | [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md) |
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
@ -60,6 +66,8 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 식품 안전 체크 | `mfds-food-safety` | 식약처 부적합 식품·식품안전나라 회수 정보를 인터뷰-first 흐름으로 프록시 조회 | 불필요 | [식품 안전 체크 가이드](docs/features/mfds-food-safety.md) |
| 한국 주식 정보 조회 | `korean-stock-search` | KRX 상장 종목 검색, 기본정보, 일별 시세 조회 | 불필요 | [한국 주식 정보 조회 가이드](docs/features/korean-stock-search.md) |
| 금감원 DART 전자공시 조회 | `k-dart` | 공시검색, 기업개황, 재무제표, 배당, 증자/감자, 감사의견, 주요사항보고서 등 14개 endpoint | 필요 | [금감원 DART 전자공시 조회 가이드](docs/features/k-dart.md) |
| 잡코리아 인재검색 | `jobkorea-talent-search` | 잡코리아 기업회원 로그인 세션에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [잡코리아 인재검색 가이드](docs/features/jobkorea-talent-search.md) |
| 사람인 인재풀 검색 | `saramin-talent-search` | 사람인 기업회원 인재풀에서 마스킹 후보 정보를 읽고 유료 열람 전 shortlist 작성 | 필요 | [사람인 인재풀 검색 가이드](docs/features/saramin-talent-search.md) |
| 대신증권 리포트 조회 | `daishin-report-search` | GitHub Pages에 공개된 대신증권 리포트 HTML 미러에서 최신 리포트 목록, 원문, 설명 페이지, Rating/Target 표를 조회 | 불필요 | [대신증권 리포트 조회 가이드](docs/features/daishin-report-search.md) |
| 국가데이터처 KOSIS 통계 조회 | `kosis-stats` | 국가데이터처가 운영하는 KOSIS(국가통계포털) Open API로 통계표 검색·메타·데이터·대용량 자료 조회 (조회 전용) | 일반 조회 불필요 (`bigdata`/`--direct` 필요) | [국가데이터처 KOSIS 통계 조회 가이드](docs/features/kosis-stats.md) |
| 조선왕조실록 검색 | `joseon-sillok-search` | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
@ -73,7 +81,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) |
@ -151,7 +159,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [고속버스 예매](docs/features/express-bus-booking.md)
- [시외버스 예매](docs/features/intercity-bus-booking.md)
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
@ -164,6 +172,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [사업자 실사 종합 가이드](docs/features/biz-health-check.md)
- [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md)
- [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md)
- [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md)
- [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md)
- [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.md)
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
- [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)

79
biz-health-check/SKILL.md Normal file
View file

@ -0,0 +1,79 @@
---
name: biz-health-check
description: 사업자등록번호 하나로 "이 사업자, 실제 문제 없나"를 확인한다 — 국세청 사업자등록 상태·국민연금 가입 사업장·국세 체납 명단·금융위 법인개요·조달청 부정당제재·지방행정 인허가 영업상태를 무료 공공 데이터로 교차 조회해 사실만 병렬하는 실사 리포트(점수·등급·위험 판정 없음).
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 사업자 실사 복합 조회 (biz-health-check)
## What this skill does
사업자등록번호(+상호/지역)를 입력하면 무료 공공 데이터 6종을 한 번에 교차 조회해 실사 리포트 한 장을 만든다. 같은 레포의 단품 스킬 helper를 그대로 재사용한다(단일 진실원천).
| 섹션 | 데이터 | 단품 스킬 | 경로 |
|---|---|---|---|
| 국세청 상태 | 계속/휴업/폐업·과세유형 | `nts-business-registration` | proxy |
| 국민연금 | 가입자수·당월 고지금액·월별 | `national-pension-workplace` | proxy |
| 체납 명단 | 고액·상습체납자 명단공개 대조 | `nts-tax-delinquency` | 직접(무인증) |
| 금융위 | 대표자·설립일·업종 법인개요 | `fsc-corporate-info` | proxy |
| 부정당제재 | 조회시점 유효 제재 | `g2b-sanctioned-supplier` | proxy |
| 인허가 영업상태 | 동네 사업장(208업종) 영업/폐업·업력 | `localdata-business-status` | 직접(무인증) |
공시 유무는 기존 `k-dart` 스킬을 함께 쓰면 된다.
## Design principles
- **점수·등급·"위험" 같은 해석 라벨을 산출하지 않는다.** 각 항목의 사실 + 출처 + 조회시각만 병렬한다. 판단은 사용자 몫이다.
- 한 항목 조회가 실패해도 전체를 막지 않고 그 항목만 정직하게 강등한다(`unavailable` + 사유).
- 단품 helper를 찾지 못하면(개별 설치 등) 해당 섹션만 건너뛰고 나머지를 진행한다.
## When to use
- "이 사업자(거래처/의뢰인) 실제 문제 없는지 한 번에 확인해줘"
- "○○○-○○-○○○○○ 살아있는 회사야? 직원은 좀 있고, 체납·입찰 제재 이력은 없어?"
## Prerequisites
- 인터넷 연결, `python3`
- 같은 레포의 단품 스킬 6종(이 복합이 helper를 재사용)
- proxy 섹션을 켜려면 hosted/self-host `k-skill-proxy` 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- proxy 섹션(국세청 상태·국민연금·금융위·부정당)은 운영 서버의 `DATA_GO_KR_API_KEY`로 동작한다. 활용신청 항목은 각 단품 스킬 문서를 따른다.
- 무인증 섹션(체납·인허가)은 키 없이 사용자 머신에서 직접 동작한다.
## Inputs
- `b_no`: 사업자등록번호 10자리(하이픈 허용) — 상태조회·부정당제재에 필요
- `--name`: 상호·법인명 — 국민연금·금융위·체납·인허가에 필요
- `--region`: 시군구 — 인허가(동네 사업장) 조회에 필요 (예: `제주제주시`)
- `--industry`: 인허가 업종(여러 번 지정 가능). 생략 시 음식점·카페·숙박
## CLI examples
```bash
python3 biz-health-check/scripts/biz_health_check.py 124-81-00998 --name "삼성전자"
# 동네 사업장까지 포함
python3 biz-health-check/scripts/biz_health_check.py --name "호텔샬롬" --region 제주제주시 --industry 숙박업
```
## Output
- `sections`: 6개 섹션 각각의 `data`(단품 응답 원문) 또는 `status: unavailable` + `note`
- 입력에 따라 일부 섹션은 생략된다(예: `--name` 없으면 국민연금/금융위/체납 생략).
## Failure modes
- 섹션별 강등은 리포트에 그대로 남는다(전체 실패가 아니다).
- proxy 섹션이 `503/502`면 운영 서버 키·활용신청 문제 — 각 단품 스킬 문서 참고.
## Official surfaces
- 각 단품 스킬 문서(`docs/features/<skill>.md`)의 공식 출처를 따른다.

View file

@ -0,0 +1,161 @@
"""Business due-diligence composite — runs the sibling k-skill providers at once.
사업자등록번호(+상호/지역) 하나로 "이 사업자, 실제 문제 없나" 무료 공공 데이터로
교차 조회해 실사 리포트 장을 만든다. 점수·등급·"위험" 라벨을 만들지 않고,
항목의 사실 + 출처 + 조회시각만 병렬한다. 판단은 사용자 몫이다.
복합 스킬은 같은 레포의 단품 스킬 helper들을 그대로 재사용한다(단일 진실원천):
- nts-business-registration 상태조회 (k-skill-proxy)
- national-pension-workplace 국민연금 사업장 (k-skill-proxy)
- fsc-corporate-info 금융위 법인개요 (k-skill-proxy)
- g2b-sanctioned-supplier 부정당제재 (k-skill-proxy)
- nts-tax-delinquency 체납 명단 (무인증 직접)
- localdata-business-status 인허가 영업상태 (무인증 직접, --region 필요)
단품 helper를 찾지 못하면 해당 항목만 정직하게 강등하고 나머지는 계속 진행한다.
"""
from __future__ import annotations
import argparse
import datetime as dt
import importlib.util
import json
import pathlib
import re
import sys
from typing import Any, Callable
KST = dt.timezone(dt.timedelta(hours=9))
_REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent.parent
# (섹션 키, 사람이 읽는 라벨, 단품 스킬 디렉토리, helper 파일명)
_SIBLINGS = {
"nts_status": ("국세청 사업자등록 상태", "nts-business-registration", "nts_business_registration.py"),
"national_pension": ("국민연금 가입 사업장", "national-pension-workplace", "national_pension_workplace.py"),
"fsc_corp": ("금융위 기업기본정보", "fsc-corporate-info", "fsc_corporate_info.py"),
"g2b_sanction": ("조달청 부정당제재", "g2b-sanctioned-supplier", "g2b_sanctioned_supplier.py"),
"tax_delinquency": ("국세 체납 명단공개", "nts-tax-delinquency", "nts_tax_delinquency.py"),
"localdata": ("지방행정 인허가 영업상태", "localdata-business-status", "localdata_business_status.py"),
}
def _now_iso() -> str:
return dt.datetime.now(KST).isoformat(timespec="seconds")
def _normalize_b_no(value: Any) -> str:
normalized = re.sub(r"\D", "", str(value or ""))
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
return normalized
def _unavailable(module_key: str, note: str) -> dict:
label, skill_dir, _ = _SIBLINGS[module_key]
return {"provider": label, "skill": skill_dir, "status": "unavailable",
"looked_up_at": _now_iso(), "data": None, "note": note}
def _load(module_key: str) -> Any | None:
"""단품 스킬 helper를 레포 레이아웃 기준 파일 경로로 로드. 없으면 None."""
_, skill_dir, filename = _SIBLINGS[module_key]
path = _REPO_ROOT / skill_dir / "scripts" / filename
if not path.exists():
return None
spec = importlib.util.spec_from_file_location(f"_bhc_{module_key}", path)
if spec is None or spec.loader is None:
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _section(module_key: str, caller: Callable[[Any], dict]) -> dict:
"""단품 helper 하나를 호출해 섹션 결과로 감싼다. 어떤 오류든 강등."""
label, skill_dir, _ = _SIBLINGS[module_key]
base = {"provider": label, "skill": skill_dir, "looked_up_at": _now_iso()}
try:
module = _load(module_key)
except Exception as err:
return {**base, "status": "unavailable", "data": None,
"note": f"단품 스킬 '{skill_dir}' helper import 실패({type(err).__name__}: {err})."}
if module is None:
return {**base, "status": "unavailable", "data": None,
"note": f"단품 스킬 '{skill_dir}' helper를 찾지 못해 건너뜀 (개별 설치 시 함께 두세요)."}
try:
data = caller(module)
status = "unavailable" if isinstance(data, dict) and (data.get("status") == "unavailable" or data.get("error")) else "ok"
return {**base, "status": status, "data": data}
except Exception as err: # 경계 계약: 한 항목 실패가 전체를 막지 않는다
return {**base, "status": "unavailable", "data": None, "note": f"조회 실패({type(err).__name__}: {err})."}
def run(b_no: str | None, name: str | None = None, region: str | None = None,
industries: list[str] | None = None, *, base_url: str | None = None) -> dict:
no = _normalize_b_no(b_no) if b_no else None
name = (name or "").strip() or None
sections: dict[str, dict] = {}
if no:
sections["nts_status"] = _section(
"nts_status", lambda m: m.query_status([no], base_url=base_url))
else:
sections["nts_status"] = _unavailable("nts_status", "사업자등록번호가 없어 상태조회 생략.")
sections["national_pension"] = _section(
"national_pension",
lambda m: m.query_workplace(name, no, base_url=base_url)) if name else \
_unavailable("national_pension", "상호(--name)가 없어 국민연금 조회 생략.")
sections["fsc_corp"] = _section(
"fsc_corp",
lambda m: m.query_corp_outline(name, no, base_url=base_url)) if name else \
_unavailable("fsc_corp", "법인명(--name)이 없어 금융위 조회 생략.")
sections["g2b_sanction"] = _section(
"g2b_sanction", lambda m: m.query_sanctions(no, base_url=base_url)) if no else \
_unavailable("g2b_sanction", "사업자등록번호가 없어 부정당제재 조회 생략.")
sections["tax_delinquency"] = _section(
"tax_delinquency", lambda m: m.lookup(name)) if name else \
_unavailable("tax_delinquency", "상호(--name)가 없어 체납 명단 조회 생략.")
if name and region:
sections["localdata"] = _section(
"localdata", lambda m: m.lookup(name, region, industries))
else:
sections["localdata"] = _unavailable("localdata", "동네 사업장 인허가 조회는 상호(--name)와 지역(--region)이 함께 필요.")
return {
"query": {"b_no": no, "name": name, "region": region, "industries": industries},
"generated_at": _now_iso(),
"disclaimer": ("무료 공공 데이터의 사실만 병렬한 실사 리포트다. 점수·등급·위험 판정은 "
"하지 않으며, 동일성·해석은 사용자가 판단한다."),
"sections": sections,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="사업자 실사 복합 조회 (단품 k-skill 6종 묶음)")
parser.add_argument("b_no", nargs="?", default=None, help="사업자등록번호 10자리(하이픈 허용)")
parser.add_argument("--name", help="상호·법인명 — 국민연금/금융위/체납/인허가 조회에 필요")
parser.add_argument("--region", help="시군구 (동네 사업장 인허가 조회용 — 예: 제주제주시)")
parser.add_argument("--industry", action="append", dest="industries", help="인허가 업종(여러 번 지정 가능)")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
report = run(args.b_no, args.name, args.region, args.industries, base_url=args.proxy_base_url)
except ValueError as err:
print(json.dumps({"error": str(err)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

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,45 @@
# 사업자 실사 종합 (biz-health-check)
`biz-health-check` 스킬은 사업자등록번호(+상호/지역) 하나로 무료 공공 데이터 6종을 한 번에 교차 조회해 실사 리포트 한 장을 만든다. 같은 레포의 단품 스킬 helper를 그대로 재사용한다(단일 진실원천).
## 묶는 단품 스킬
| 섹션 | 단품 스킬 | 경로 |
| --- | --- | --- |
| 국세청 사업자등록 상태 | `nts-business-registration` | proxy |
| 국민연금 가입 사업장 | `national-pension-workplace` | proxy |
| 국세 체납 명단공개 | `nts-tax-delinquency` | 직접(무인증) |
| 금융위 기업기본정보 | `fsc-corporate-info` | proxy |
| 조달청 부정당제재 | `g2b-sanctioned-supplier` | proxy |
| 지방행정 인허가 영업상태 | `localdata-business-status` | 직접(무인증) |
## 설계 원칙
- 점수·등급·"위험" 같은 해석 라벨을 산출하지 않는다. 각 항목의 사실 + 출처 + 조회시각만 병렬한다.
- 한 항목 조회가 실패해도 전체를 막지 않고 그 항목만 `unavailable` + 사유로 강등한다.
- 단품 helper를 찾지 못하면 해당 섹션만 건너뛰고 나머지를 진행한다.
## 인증/시크릿
- 사용자 측 필수 시크릿 없음.
- proxy 섹션(국세청 상태·국민연금·금융위·부정당)은 운영 서버의 `DATA_GO_KR_API_KEY`로 동작한다.
- 무인증 섹션(체납·인허가)은 키 없이 사용자 머신에서 직접 동작한다.
## 예시
```bash
python3 biz-health-check/scripts/biz_health_check.py 124-81-00998 --name "삼성전자"
python3 biz-health-check/scripts/biz_health_check.py --name "호텔샬롬" --region 제주제주시 --industry 숙박업
```
## 입력
- `b_no`: 사업자등록번호 10자리(하이픈 허용) — 상태조회·부정당제재에 필요
- `--name`: 상호·법인명 — 국민연금·금융위·체납·인허가에 필요
- `--region`: 시군구 — 인허가(동네 사업장) 조회에 필요
- `--industry`: 인허가 업종(여러 번 지정 가능)
## 공식 출처
- 각 단품 스킬 문서의 공식 출처를 따른다. 통합 목록은 [sources](../sources.md)의 "사업자 실사" 항목 참조.

View file

@ -0,0 +1,35 @@
# 금융위 기업기본정보 조회 (fsc-corporate-info)
`fsc-corporate-info` 스킬은 공공데이터포털의 **금융위원회_기업기본정보 서비스**(15043184, `getCorpOutline_V2`)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 법인명(`corpNm`) 기준 후보: 대표자·설립일·업종 등 upstream 필드 원문
- 사업자번호 교차검증: 응답에 `bzno`가 있으면 입력 번호와 정확 일치하는 후보를 분리(없으면 교차검증 불가 표기)
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(15043184 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 입력 제한
검색 파라미터가 `crno`(법인등록번호 13자리)/`corpNm`(법인명)뿐이라 **사업자번호 단독 조회가 불가**하다. 법인명으로 조회한다. `crno`는 사업자등록번호와 별개 번호다.
## 예시
```bash
python3 fsc-corporate-info/scripts/fsc_corporate_info.py --name "삼성전자" --b-no 124-81-00998
```
## 실패 모드
- `400 bad_request`: 법인명 미입력
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 15043184에 미신청
- 빈 결과: 법인명 불일치 — 표기를 바꿔 재시도
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/15043184/openapi.do>
- upstream: `https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2`
- 프록시 route: `GET /v1/fsc/corp-outline`

View file

@ -0,0 +1,41 @@
# 부정당제재업체 조회 (g2b-sanctioned-supplier)
`g2b-sanctioned-supplier` 스킬은 공공데이터포털의 **조달청 나라장터 사용자정보 서비스**(15129466, `getUnptRsttCorpInfo02`)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 사업자등록번호 정확 일치(`inqryDiv=1`)로 **조회시점 현재 유효한** 부정당제재 조회
- 반환: 제재 시작/종료일자, 제재기관명, 계약법구분, 제재근거법률 등 upstream 필드 원문
## 적용 범위 한계
upstream 명세상 다음은 제공되지 않는다(과거 이력 조회가 아니다).
- 조회시점에 제재만료·해제된 건
- 나라장터 미등록업체·개인에 대한 제재
만료 이력까지 보려면 나라장터(<https://www.g2b.go.kr>)에서 수동 확인이 필요하다.
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(15129466 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 예시
```bash
python3 g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py --bizno 124-81-00998
```
## 실패 모드
- `400 bad_request`: 사업자번호가 10자리가 아님
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 15129466에 미신청
- `total_count: 0`: 조회시점 유효 제재 없음(만료·미등록업체는 미제공임에 유의)
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/15129466/openapi.do>
- upstream: `https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02`
- 수동 대조: 나라장터 <https://www.g2b.go.kr>
- 프록시 route: `GET /v1/g2b/sanctioned-supplier`

View file

@ -0,0 +1,67 @@
# 잡코리아 인재검색 가이드
## 이 기능으로 할 수 있는 일
- 잡코리아 기업회원 인재검색 화면에서 구인/채용 조건을 입력해 후보를 찾는다.
- 사용자가 직접 로그인한 브라우저 세션에서 현재 보이는 마스킹된 목록/이력서 정보를 읽는다.
- 유료 이력서 열람 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 잡코리아 구인자/채용 담당자가 접근 가능한 기업회원 계정과 사용자 직접 로그인이 필요하다.
- 에이전트는 비밀번호, OTP, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 열람, 마스킹 해제, 연락처 확인, 포지션 제안, 스크랩, 메모, 후보 상태 변경은 자동으로 하지 않는다.
- 비로그인 공개 목록 fallback은 가능하지만 정확도가 낮으므로 `목록 기반 1차 shortlist`로 표시한다.
## 공식 표면
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 잡코리아 기업 인재검색 페이지를 연다.
2. 로그인 상태를 확인한다. 로그인되지 않았으면 사용자가 열린 브라우저에서 직접 로그인한다.
3. 직무/키워드/경력/지역/제외 조건을 입력한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 유료 열람이나 연락처 확인이 아닌 일반 상세/마스킹 이력서만 연다.
6. 현재 보이는 정보만 근거로 점수화한다.
7. URL과 검토 수준을 포함해 유료 열람 추천 후보를 정리한다.
## 결과 형식
```text
잡코리아 인재 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인 마스킹 이력서 / 비로그인 목록 fallback
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사이트 UI 변경 시 브라우저 추출 selector를 조정해야 할 수 있다.
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

View file

@ -1,106 +1,113 @@
# 카카오톡 Mac CLI 가이드
# 카카오톡 Mac 아카이브 검색 가이드
## 이 기능으로 할 수 있는 일
- macOS에서 카카오톡 최근 대화 목록 확인
- 특정 채팅방 최근 메시지 읽기
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
- Apple Silicon macOS에서 `katok`으로 카카오톡 로컬 대화 아카이브 생성
- keyword, BM25, semantic 검색
- 검색 결과의 chunk id로 원문, 주변 맥락, parent window 조회
- 검색 전 freshness 확인과 sync/index 필요 여부 판단
이 가이드는 기존 `kakaotalk-mac` 스킬 경로를 유지하지만 실행 표면은 `katok` CLI다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 포함하지 않는다.
## 먼저 필요한 것
- macOS
- Apple Silicon macOS
- KakaoTalk for Mac 설치
- Homebrew
- `brew install silver-flight-group/tap/kakaocli`
- `python3` 3.10+
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
- 터미널 앱에 **Full Disk Access****Accessibility** 권한 부여
- Homebrew 또는 Cargo
- `katok` CLI
- 현재 터미널 앱의 Full Disk Access 권한
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
## 설치
Homebrew:
```bash
brew install mas
mas account
mas install 869223134
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
```
## 입력값
Cargo:
- 채팅방 이름
- 검색 키워드
- 최근 범위(`--since 1h`, `--since 7d` 등)
- 전송 메시지 본문
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부(`--me`, `--dry-run`)
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
Cargo 설치 후 `katok`이 보이지 않으면 `$HOME/.cargo/bin`을 shell PATH에 추가한다.
## 개인 정보와 안전 규칙
- Do not inspect local database internals from this skill.
- Do not directly read KakaoTalk DB files.
- Do not handle auth caches or decryption material.
- live macOS 카카오톡 ingestion은 `katok sync --source macos --json`으로만 수행한다.
- 검색 결과는 snippet과 chunk id 중심으로 먼저 다룬다.
- 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 chunk 원문을 조회한다.
## 기본 흐름
1. KakaoTalk for Mac 과 `kakaocli` 가 설치되어 있는지 확인한다.
2. `kakaocli status`, `kakaocli auth` 로 권한과 DB 접근이 되는지 먼저 확인한다.
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
6. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
1. `katok doctor --json`으로 freshness와 준비 상태를 확인한다.
2. Full Disk Access 설정이 필요하면 `katok permissions macos`로 시스템 설정 화면을 연다.
3. 앱 설치, container, DB 파일 접근 진단이 필요할 때만 `katok doctor --macos-probe --json`을 실행한다.
4. 최신성이 중요하거나 sync 권장이 있으면 `katok sync --source macos --json`을 실행한다.
5. semantic search 전에 index 권장이 있으면 `katok index --json`을 실행한다.
6. 질의 성격에 따라 `katok search keyword`, `katok search bm25`, `katok search semantic`을 선택한다.
7. 사용자가 지정한 결과만 `katok chunk get`, `katok chunk context`, `katok chunk parent`로 연다.
## 예시
```bash
kakaocli status
kakaocli auth
python3 scripts/kakaotalk_mac.py auth --refresh
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
python3 scripts/kakaotalk_mac.py search "회의" --json
kakaocli chats --limit 10 --json
kakaocli messages --chat "지수" --since 1d --json
kakaocli search "회의" --json
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --dry-run
katok doctor --json
katok permissions macos
katok doctor --macos-probe --json
katok sync --source macos --json
katok index --json
katok search keyword "계약서" --json
katok search bm25 "지난주 미팅 자료" --json
katok search semantic "최근에 논의한 세금 신고 일정" --json
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
## helper 가 해결하는 문제
## 검색 방식 선택
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
`katok search keyword`는 정확한 문자열, 이름, 계좌번호, 고유명사처럼 그대로 기억나는 값을 찾을 때 쓴다.
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
- `kakaocli status` 는 정상이어도 `auth``user_id 자동 감지 실패` 로 끝날 수 있다.
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
`katok search bm25`는 여러 단어가 섞인 일반 질의에 쓴다.
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
`katok search semantic`은 표현이 정확히 기억나지 않지만 의미가 비슷한 대화를 찾을 때 쓴다. `katok doctor --json`에서 semantic index 갱신이 필요하다고 나오면 먼저 `katok index --json`을 실행한다.
- plist 에서 후보 `user_id` 와 active hash 를 읽는다.
- hash recovery 가 필요하면 더 긴 검색으로 실제 `user_id` 를 찾는다.
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
## chunk 조회
## 메시지 삭제
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
검색 결과에서 더 넓은 맥락이 필요할 때만 chunk 명령을 사용한다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
- UI 삭제 단계는 활성 채팅방을 확인하고, 선택된 outbound DB 메시지의 정규화된 텍스트가 대화 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 진행한다. 로컬 DB message id가 UI bubble identity를 직접 증명하는 것은 아니므로, 메시지 텍스트가 비어 있거나 첨부/비텍스트이거나 보이지 않거나 정규화 후 같은 텍스트가 여러 개이거나 최종 확인 버튼을 클릭할 수 없으면 실패한다.
- `chats`, `messages`, `search`, `schema` 는 read-only 이지만 `delete` / `delete-last` 는 side effect 이다.
- `chunk get`: 해당 chunk 원문 조회
- `chunk context`: 같은 채팅방의 바로 앞뒤 micro chunk 조회
- `chunk parent`: semantic search가 사용한 더 큰 parent window 조회
## Synthetic QA
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 아래 경로를 쓴다.
```bash
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
KATOK_EMBEDDER=local-test katok index --json
KATOK_EMBEDDER=mock katok index --json
```
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
## 주의할 점
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
- Apple Silicon macOS 전용이다.
- Intel macOS는 packaged local EmbeddingGemma 경로의 지원 대상이 아니다.
- Full Disk Access는 사용자가 System Settings에서 직접 허용해야 한다.
- `katok doctor --macos-probe --json`은 macOS app-data 접근 prompt를 띄울 수 있으므로 setup 진단이 필요할 때만 실행한다.
- 이 스킬은 read/search/retrieve 전용이며 메시지 전송과 삭제를 지원하지 않는다.

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

@ -0,0 +1,44 @@
# 인허가 영업상태 조회 (localdata-business-status)
`localdata-business-status` 스킬은 행정안전부 **지방행정 인허가데이터(LOCALDATA)**의 지역별 CSV를 `file.localdata.go.kr`에서 직접 받아 동네 사업장의 영업상태를 조회한다.
## 제공 기능
- 영업상태(영업/휴업/폐업)·상세영업상태·인허가일자(업력)·폐업일자·업태구분·도로명/지번 주소·데이터갱신시점
- 인허가 업종 **208종 전체** 지원 — 한글명("약국", "숙박업", "일반음식점")으로 지정 가능
## 인증/시크릿
없다. 무인증 공개 파일 서버이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다. helper는 stdlib만 쓴다(추가 의존성 없음). 받은 파일은 1일 로컬 캐시한다.
## 입력/동일성 경계
- 전국 통파일이 업종당 수백 MB라 시군구 단위 지역 지정(`--region`)이 필요하다.
- 자료에 **사업자등록번호가 수록되지 않아** 상호(사업장명) 문자열 매칭만 가능하다. 동명 상호 가능성은 사용자가 판단한다.
- 자료는 매일 갱신되며 2일 전 기준으로 현행화된다.
## 예시
```bash
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "호텔샬롬" --region 제주제주시 --industry 숙박업
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "○○약국" --region 서울종로구 --industry 약국
```
## 입력
- `--name`: 상호(사업장명) — 필수
- `--region`: 시군구 — 필수 (예: `제주제주시`, `서울종로구`)
- `--industry`: 업종 slug 또는 한글명(여러 번 지정 가능). 생략 시 일반음식점·휴게음식점·숙박업
## 실패 모드
- `unavailable` + 안내: 상호/지역 미입력, 지역·업종 특정 실패(후보 나열), 다운로드 실패 — 수동 확인 URL 제공
- 0건: 매치 없음
## 공식 출처
- 인허가 영업상태: `https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>` (무인증, Referer 필요, CP949 CSV)
- 본체: <https://www.localdata.go.kr>

View file

@ -0,0 +1,38 @@
# 국민연금 가입 사업장 조회 (national-pension-workplace)
`national-pension-workplace` 스킬은 공공데이터포털의 **국민연금공단_국민연금 가입 사업장 내역 서비스**(3046071, V2)를 `k-skill-proxy` 경유로 호출한다.
## 제공 기능
- 가입 사업장 후보: 사업장명 + 사업자번호 앞 6자리로 매칭, 자료생성년월별 중복은 사업장당 최신 월로 정리
- 단일 사업장 특정 시 상세: 가입자수(`jnngpCnt`), 당월 고지금액(`crrmmNtcAmt`), 신규취득/상실 인원
- 월별 가입 현황 시계열
## 인증/시크릿
사용자 로컬 시크릿은 필요 없다. upstream `DATA_GO_KR_API_KEY`는 프록시 서버에만 둔다(3046071 활용신청 필요). self-host 프록시는 `KSKILL_PROXY_BASE_URL`로 지정한다.
## 공개 범위
- 사업자번호는 앞 6자리만 공개(뒷자리 마스킹)되어 사업장명이 필수다. 후보가 여럿이면 동일성을 단정하지 않고 목록을 그대로 돌려준다.
- 법인·근로자 일정 규모 이상 사업장 위주로 공개되며, 소규모/개인 사업장은 미공개일 수 있다.
## 예시
```bash
python3 national-pension-workplace/scripts/national_pension_workplace.py \
--name "삼성전자(주)" --b-no 124-81-00998
```
## 실패 모드
- `400 bad_request`: 사업장명 미입력
- `503 upstream_not_configured`: 프록시에 `DATA_GO_KR_API_KEY` 없음
- `502 upstream_forbidden`: 프록시 키가 3046071에 미신청
- `selected_candidate: null`: 후보 다수 — 사용자가 특정
## 공식 출처
- 공공데이터포털: <https://www.data.go.kr/data/3046071/openapi.do>
- upstream: `https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2`
- 프록시 route: `GET /v1/national-pension/workplace`

View file

@ -0,0 +1,31 @@
# 국세 체납 명단공개 검색 (nts-tax-delinquency)
`nts-tax-delinquency` 스킬은 국세청 누리집의 **고액·상습체납자 명단공개**(국세기본법 제85조의5)를 무인증 공개 검색으로 직접 조회한다.
## 제공 기능
- 법인 명단: 법인명으로 검색 — 공개년도·법인명·대표자·업종·소재지·총 체납액·세목·체납건수·체납요지
- 개인 명단: 상호로 검색 — 공개년도·성명·연령·상호·직업·주소·총 체납액·세목·체납요지
## 인증/시크릿
없다. 인증 없이 동작하는 공개 read-only 검색이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다. helper는 stdlib만 쓴다(추가 의존성 없음).
## 동일성 경계
명단공개 자료에는 **사업자등록번호가 수록되지 않는다.** 상호·법인명 문자열 일치 후보의 공개 사실만 나열하며, 동명 상호 가능성은 사용자가 판단한다.
## 예시
```bash
python3 nts-tax-delinquency/scripts/nts_tax_delinquency.py --name "○○건설"
```
## 실패 모드
- `unavailable` + 안내: 상호 미입력, 네트워크 오류, 페이지 구조 변경 추정 — 수동 확인 URL 제공. HTML 스크래핑이라 마커가 어긋나면 즉시 강등한다.
- 0건: 두 명단 모두 매치 없음.
## 공식 출처
- 명단공개 검색: <https://www.nts.go.kr/nts/ad/openInfo/selectList.do>

View file

@ -0,0 +1,67 @@
# 사람인 인재풀 검색 가이드
## 이 기능으로 할 수 있는 일
- 사람인 기업회원 인재풀에서 구인/채용 조건에 맞는 후보를 검색한다.
- 사용자가 직접 로그인/2차 인증을 완료한 브라우저 세션에서 현재 보이는 마스킹 후보 정보를 읽는다.
- 유료 열람/연락처 확인/제안 발송 전에 후보 적합도를 비교하고 shortlist를 만든다.
- 영업, 마케팅, 디자인, PM/PO, HR, 재무, 운영, 제조, 법무/총무, 개발/데이터 등 전 직무에 사용할 수 있다.
## 먼저 알아둘 점
- 사람인 구인자/채용 담당자가 접근 가능한 기업회원 로그인과 첫 기기 2차 인증이 필요할 수 있다.
- 에이전트는 비밀번호, OTP, 인증번호, 세션 쿠키를 요청하거나 저장하지 않는다.
- 유료 이력서 열람, 연락처 확인, 포지션 제안, 스크랩/관심후보/메모/상태 변경, 결제는 자동으로 하지 않는다.
- 일반 후보 상세/프로필 링크를 열어 현재 보이는 마스킹 정보만 읽는다.
## 공식 표면
- 사람인 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
## 입력값
- 채용 직무명
- 경력 범위
- 지역
- 필수 경험/스킬/업종
- 우대 경험/성과/툴
- 제외할 업무/업종/경력 패턴
- 유료 열람 추천 인원 수
## 기본 흐름
1. 사람인 인재풀 검색 페이지를 연다.
2. 로그인/2차 인증이 필요하면 사용자가 열린 브라우저에서 직접 완료한다.
3. 검색어, 직무/직종, 경력, 지역, 최근 업데이트/정렬, 제외 조건을 적용한다.
4. 결과 목록에서 후보 pool을 만든다.
5. 최종 추천 전에는 가능한 후보 상세/프로필 페이지를 열어 현재 보이는 마스킹 정보를 확인한다.
6. 유료 열람/연락처/제안/스크랩/메모/상태 변경 버튼은 누르지 않는다.
7. URL, 검토 수준, 점수, 근거, 리스크를 포함해 shortlist를 정리한다.
## 결과 형식
```text
사람인 인재풀 shortlist
검색 조건
- 포지션: ...
- 필수 조건: ...
- 우대 조건: ...
- 제외 조건: ...
- 경력/지역: ...
- 모드: 로그인/인증 완료 브라우저 세션의 마스킹 후보 정보
유료 열람 추천 Top N
1. 후보 A
- 점수: ...
- 근거: ...
- 검토 수준: 상세 이력 확인 기반 / 목록 기반 1차
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
```
## 제한사항
- 사람인 UI, 계정 권한, 유료 상품 상태에 따라 보이는 정보가 다르다.
- 상세 접근이 전부 유료 벽이면 `목록 기반 1차 shortlist`로 낮은 신뢰도를 표시한다.
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.

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)를 본다.
@ -331,14 +319,30 @@ HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm ins
### macOS 바이너리
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
카카오톡 Mac 아카이브 검색은 npm 패키지가 아니라 `katok` CLI 설치를 사용한다.
```bash
brew install silver-flight-group/tap/kakaocli
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
```
Cargo로 설치할 수도 있다.
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
`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,9 +93,9 @@
- 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
- `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
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
- `NomaDamas/katok`: https://github.com/NomaDamas/katok
- `katok` macOS first-run docs: https://github.com/NomaDamas/katok/blob/main/docs/macos-first-run.md
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
- 바른한글 메인: https://nara-speller.co.kr/speller/
@ -217,3 +220,17 @@
- **기술보증기금**: https://koreatech.or.kr
- **KOTRA**: https://www.kotra.or.kr
- **중소벤처기업금융공단**: https://www.sbc.or.kr
### 사업자 실사 (biz-health-check 스킬군)
- 국세청 사업자등록정보 진위확인 및 상태조회: https://www.data.go.kr/data/15081808/openapi.do
- 국민연금공단 국민연금 가입 사업장 내역: https://www.data.go.kr/data/3046071/openapi.do
- 국민연금 endpoint(V2): https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2 (getBassInfoSearchV2 / getDetailInfoSearchV2 / getPdAcctoSttusInfoSearchV2, 요청 파라미터 camelCase)
- 금융위원회 기업기본정보: https://www.data.go.kr/data/15043184/openapi.do
- 금융위 기업개요 endpoint: https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2
- 조달청 나라장터 사용자정보 서비스(부정당제재업체정보조회 포함): https://www.data.go.kr/data/15129466/openapi.do
- 부정당제재 endpoint: https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02 (inqryDiv=1 사업자번호 정확일치, 조회시점 유효 제재만)
- 국세청 고액·상습체납자 명단공개(무인증): https://www.nts.go.kr/nts/ad/openInfo/selectList.do
- 지방행정 인허가데이터 LOCALDATA 파일 다운로드(무인증, CP949 CSV): https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>
- LOCALDATA 본체: https://www.localdata.go.kr
- 잡코리아 기업 인재검색: https://www.jobkorea.co.kr/corp/person/find — 기업회원 로그인 세션에서 마스킹 이력서/목록을 읽는 브라우저 기반 경로. 유료 열람/마스킹 해제/포지션 제안은 수동 확인 대상.
- 사람인 기업회원 인재풀 검색: https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search — 기업회원 로그인 및 첫 기기 2차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.

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

@ -0,0 +1,67 @@
---
name: fsc-corporate-info
description: 금융위원회 기업기본정보(법인 개요)를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 법인명으로 대표자·설립일·업종 등 법인 개요를 확인하고, 응답에 사업자번호가 있으면 입력 번호와 교차검증한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 금융위 기업기본정보(법인 개요) 조회
## What this skill does
공공데이터포털의 **금융위원회_기업기본정보 서비스**(data.go.kr 15043184, `getCorpOutline_V2`)를 `k-skill-proxy` 경유로 호출해 법인 개요를 조회한다.
- 법인명(`corpNm`) 기준 후보 목록: 대표자·설립일·업종 등 upstream 필드 원문
- 사업자번호 교차검증: 응답 item에 `bzno`가 있으면 입력 사업자번호와 정확 일치하는 후보를 분리한다 (`bzno`가 없으면 교차검증 불가 사실을 그대로 표기)
이 API의 검색 파라미터는 `crno`(법인등록번호 13자리)/`corpNm`(법인명)뿐이라 **사업자번호 단독 조회가 불가**하다. 법인명으로 조회한다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. upstream 사실 + 출처만 담는다.
- `crno`(법인등록번호)는 사업자등록번호와 별개 번호임을 혼동하지 않는다.
## When to use
- "이 법인 대표자·설립일·업종 개요 확인해줘"
- "법인명으로 기업 기본정보 조회해줘"
## Prerequisites
- 인터넷 연결, `python3`
- `scripts/fsc_corporate_info.py` helper
- hosted/self-host `k-skill-proxy``/v1/fsc/corp-outline` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `금융위원회_기업기본정보` 활용신청이 되어 있어야 한다.
## Inputs
- `--name`: 법인명(`corpNm`) — 필수
- `--b-no`: 사업자등록번호. 응답에 `bzno`가 있을 때 교차검증에만 쓰인다.
## CLI examples
```bash
python3 fsc-corporate-info/scripts/fsc_corporate_info.py \
--name "삼성전자" --b-no 124-81-00998
```
## Failure modes
- `400 bad_request`: 법인명을 주지 않음.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
- `502 upstream_forbidden`: 프록시 키가 15043184에 활용신청되지 않음.
- 빈 결과: 법인명 불일치 — 표기를 바꿔 재시도.
## Official surfaces
- 공공데이터포털: <https://www.data.go.kr/data/15043184/openapi.do>
- upstream: `https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2`
- 프록시 route: `GET /v1/fsc/corp-outline`

View file

@ -0,0 +1,109 @@
"""FSC corporate-outline lookup via k-skill-proxy.
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
query and reads the structured response. No user secret is required.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
ROUTE = "/v1/fsc/corp-outline"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as error:
raise ApiError("fsc corp-outline proxy returned invalid JSON.") from error
if not isinstance(payload, dict):
raise ApiError("fsc corp-outline proxy returned a non-object JSON payload.")
return payload
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code) from error
raise ApiError(f"fsc corp-outline proxy request failed with HTTP {error.code}", status_code=error.code) from error
except urllib.error.URLError as error:
raise ApiError(f"fsc corp-outline proxy request failed: {error.reason}") from error
def query_corp_outline(name: str, b_no: str | None = None, *, base_url: str | None = None,
read_json: Any = read_json_response) -> dict[str, Any]:
name = _text_or_none(name)
if not name:
raise ValueError("법인명(corpNm)을 입력하세요. 이 API는 사업자번호 단독 조회가 불가합니다.")
params = {"name": name}
if _text_or_none(b_no):
digits = re.sub(r"\D", "", str(b_no))
if not re.fullmatch(r"\d{10}", digits):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
params["b_no"] = digits
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(url, headers={
"Accept": "application/json",
"User-Agent": "k-skill-fsc-corporate-info/1.0",
}, method="GET")
return read_json(request)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="금융위 기업기본정보(법인 개요) 조회 (k-skill-proxy 경유)")
parser.add_argument("--name", required=True, help="법인명(corpNm) — 필수")
parser.add_argument("--b-no", help="사업자등록번호 — 응답에 bzno가 있을 때 교차검증에만 사용")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = query_corp_outline(args.name, args.b_no, base_url=args.proxy_base_url)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,71 @@
---
name: g2b-sanctioned-supplier
description: 조달청 나라장터 부정당제재업체정보를 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업자등록번호 정확 일치로 조회시점 현재 유효한 입찰참가자격 제한(부정당제재)의 기간·제재기관·근거법률을 확인한다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 나라장터 부정당제재업체정보 조회
## What this skill does
공공데이터포털의 **조달청 나라장터 사용자정보 서비스**(data.go.kr 15129466, `getUnptRsttCorpInfo02`)를 `k-skill-proxy` 경유로 호출해, 사업자등록번호 정확 일치(`inqryDiv=1`)로 **조회시점 현재 유효한** 부정당제재를 조회한다.
- 반환: 제재 시작/종료일자, 제재기관명, 계약법구분, 제재근거법률 등 upstream 필드 원문
## Coverage boundary
upstream 명세상 다음은 **제공되지 않는다** — 과거 이력 조회가 아니다.
- 조회시점에 제재만료·해제된 건
- 나라장터 미등록업체·개인에 대한 제재
만료 이력까지 보려면 나라장터(<https://www.g2b.go.kr>)에서 수동 확인이 필요하다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. upstream 사실 + 출처 + 적용범위 한계만 담는다.
## When to use
- "이 회사 입찰 제재(부정당제재) 이력 있어?"
- "거래/계약 전에 부정당업자 제재 여부 확인해줘"
## Prerequisites
- 인터넷 연결, `python3`
- `scripts/g2b_sanctioned_supplier.py` helper
- hosted/self-host `k-skill-proxy``/v1/g2b/sanctioned-supplier` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `조달청_나라장터 사용자정보 서비스`(부정당제재업체정보조회 포함) 활용신청이 되어 있어야 한다.
## Inputs
- `--bizno`: 사업자등록번호 10자리(하이픈 허용) — 필수
## CLI examples
```bash
python3 g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py --bizno 124-81-00998
```
## Failure modes
- `400 bad_request`: 사업자번호가 10자리가 아님.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
- `502 upstream_forbidden`: 프록시 키가 15129466에 활용신청되지 않음.
- `total_count = 0`: 조회시점 현재 유효한 제재 없음 (만료·미등록업체는 미제공임에 유의).
## Official surfaces
- 공공데이터포털: <https://www.data.go.kr/data/15129466/openapi.do>
- upstream: `https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02`
- 수동 대조: 나라장터 <https://www.g2b.go.kr>
- 프록시 route: `GET /v1/g2b/sanctioned-supplier`

View file

@ -0,0 +1,110 @@
"""Procurement (나라장터) sanctioned-supplier lookup via k-skill-proxy.
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
query and reads the structured response. No user secret is required.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
ROUTE = "/v1/g2b/sanctioned-supplier"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def normalize_bizno(value: Any) -> str:
raw = _text_or_none(value)
if not raw:
raise ValueError("사업자등록번호(bizno)를 입력하세요.")
normalized = re.sub(r"\D", "", raw)
if not re.fullmatch(r"\d{10}", normalized):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다.")
return normalized
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as error:
raise ApiError("g2b sanction proxy returned invalid JSON.") from error
if not isinstance(payload, dict):
raise ApiError("g2b sanction proxy returned a non-object JSON payload.")
return payload
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code) from error
raise ApiError(f"g2b sanction proxy request failed with HTTP {error.code}", status_code=error.code) from error
except urllib.error.URLError as error:
raise ApiError(f"g2b sanction proxy request failed: {error.reason}") from error
def query_sanctions(bizno: str, *, base_url: str | None = None,
read_json: Any = read_json_response) -> dict[str, Any]:
normalized = normalize_bizno(bizno)
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode({'bizno': normalized})}"
request = urllib.request.Request(url, headers={
"Accept": "application/json",
"User-Agent": "k-skill-g2b-sanctioned-supplier/1.0",
}, method="GET")
return read_json(request)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="나라장터 부정당제재업체정보 조회 (k-skill-proxy 경유)")
parser.add_argument("--bizno", required=True, help="사업자등록번호 10자리(하이픈 허용)")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = query_sanctions(args.bizno, base_url=args.proxy_base_url)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,130 @@
---
name: jobkorea-talent-search
description: 잡코리아 기업회원 로그인 세션으로 유료 열람 전 마스킹된 인재 이력서를 검색·비교해 채용 검토용 shortlist를 만듭니다.
license: MIT
metadata:
category: recruiting
locale: ko-KR
phase: v1
---
# jobkorea-talent-search
잡코리아 기업 인재검색에서 유료 열람/포지션 제안 전에 현재 보이는 마스킹 이력서와 목록 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
## Use when
- 사용자가 잡코리아에서 후보를 찾아달라고 요청한다.
- 기업회원 로그인 세션에서 마스킹 이력서/목록을 비교해야 한다.
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
## Hard boundaries
Allowed:
- 잡코리아 기업회원 브라우저 세션 열기 및 검색 필터 입력
- 현재 보이는 마스킹 목록/이력서/프로필 읽기
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
Never do without explicit user handoff/confirmation:
- 유료 이력서 열람, 마스킹 해제, 연락처 확인
- 포지션 제안 발송, 스크랩, 메모 저장, 후보 상태 변경
- 결제/유료 크레딧 사용
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
- 후보 개인정보 장기 저장 또는 대량 수집
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
## Primary access
Open:
```text
https://www.jobkorea.co.kr/corp/person/find
```
If not logged in, pause and show:
```text
잡코리아 인재검색은 경력 상세/포트폴리오/마스킹 이력서 확인을 위해 기업회원 로그인이 필요합니다.
제가 브라우저로 잡코리아 기업 인재검색 페이지를 열어둘게요.
열린 브라우저에서 직접 로그인해 주세요. 비밀번호나 인증정보는 저에게 알려주지 마세요.
로그인이 끝나면 “로그인했어”라고 알려주시면, 같은 브라우저 세션에서 검색을 이어가겠습니다.
```
Resume only in the same browser session after the user confirms login.
## Input normalization
Extract or infer:
- role_title
- must_have / nice_to_have
- negative_keywords
- career min/max
- location/work_area
- role-specific evaluation signals
- limit / requested Top N
Do not block on missing details when a reasonable first search is possible.
## Workflow
1. Open the primary URL and verify corporate login.
2. Ask the user to log in manually only when required; never handle credentials.
3. Apply filters: keyword, 직무/스킬, 지역, 경력, recent activity/update, exclusions when supported.
4. Build a candidate pool from visible rows.
5. Before final Top N, open normal resume/detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary, portfolio links if visible, recent activity.
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
8. Return Korean shortlist with direct URL per recommended candidate.
If detail pages are inaccessible or paid-walled, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
## No-login fallback
Use only when the user cannot or will not log in. It is low-confidence because it cannot inspect resume details.
```bash
python3 jobkorea-talent-search/scripts/jobkorea_talent_search.py --keyword "퍼포먼스 마케터 GA4" --work-area "서울" --career-min 3 --career-max 7 --limit 20
```
## URL extraction guidance
Every recommended candidate needs a direct JobKorea resume/profile URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
## Output shape
```text
잡코리아 인재 shortlist
검색 조건
- 포지션: ...
- 필수/우대/제외 조건: ...
- 경력/지역: ...
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
유료 열람 추천 Top N
1. 후보 A
- 점수: 88/100
- 근거: ...
- 보이는 경력/성과: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
보류 후보
- ...
검색 한계
- 마스킹/현재 표시 정보만 분석했음
- 연락처/실명/비공개 정보는 열람하지 않음
- 유료 액션은 실행하지 않음
```
## Failure modes
- Login/2FA required: open the page and let the user complete it manually.
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to low-confidence fallback.
- Paid wall/contact wall: stop and mark as manual paid review needed.
- Empty results: adjust keywords, career, region, update/relevance filters.
- UI changed: rediscover the visible form/data flow before updating scripts.

View file

@ -0,0 +1,27 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
BASE_URL: Final = "https://www.jobkorea.co.kr"
FIND_PATH: Final = "/corp/person/find"
AJAX_PATH: Final = "/corp/person/detailsearchajax"
DEFAULT_UA: Final = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
)
@dataclass(frozen=True, slots=True)
class Candidate:
rno: str
url: str
name: str = ""
meta: str = ""
career: str = ""
education: str = ""
locations: str = ""
salary: str = ""
skills: str = ""
badges: str = ""
raw_summary: str = ""

View file

@ -0,0 +1,186 @@
from __future__ import annotations
import html
import re
import urllib.parse
from jobkorea_talent_models import BASE_URL, Candidate
ACTION_CONTROL_RE = re.compile(
r"^(?:스크랩\s*\d*|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)$"
)
ACTION_CONTROL_INLINE_RE = re.compile(
r"(?:스크랩\s*\d+|저장하기|닫기|포지션\s*제안|메모하기|프로필\s*확인|이력서\s*확인|펼쳐보기|접기|이전|다음)"
)
RESUME_LINK_RE = re.compile(r'href="(?P<href>/corp/person/find/resume/view\?rNo=(?P<rno>\d+))"')
def clean_text(value: str) -> str:
value = html.unescape(value)
value = re.sub(r"<script[\s\S]*?</script>", " ", value, flags=re.I)
value = re.sub(r"<style[\s\S]*?</style>", " ", value, flags=re.I)
value = re.sub(r"<[^>]+>", " ", value)
value = re.sub(r"[ \t\r\f\v]+", " ", value)
value = re.sub(r"\n\s*\n+", "\n", value)
return value.strip()
def is_action_control_label(value: str) -> bool:
label = re.sub(r"\s+", " ", html.unescape(value)).strip()
return bool(label and ACTION_CONTROL_RE.match(label))
def filter_action_control_text(value: str) -> str:
lines = []
for line in value.splitlines():
label = line.strip()
if not label or is_action_control_label(label):
continue
label = ACTION_CONTROL_INLINE_RE.sub(" ", label)
label = re.sub(r"\s+", " ", label).strip()
if label:
lines.append(label)
return "\n".join(lines).strip()
def row_contains_other_resume(candidate_markup: str, rno: str) -> bool:
refs: list[str] = []
for href_rno, data_rno in re.findall(r"rNo=(\d+)|data-rno=[\"'](\d+)[\"']", candidate_markup):
refs.append(href_rno or data_rno)
return any(ref != rno for ref in refs)
def extract_regex_candidate_markup(markup: str, match: re.Match[str], rno: str) -> str:
row_start = markup.rfind("<tr", 0, match.start())
if row_start >= 0:
row_open_end = markup.find(">", row_start, match.start())
row_end = markup.find("</tr>", match.end())
row_open = markup[row_start : row_open_end + 1] if row_open_end >= 0 else ""
if row_end >= 0 and f'data-rno="{rno}"' in row_open:
return markup[row_start : row_end + len("</tr>")]
booth_start = markup.rfind('<div class="booth"', 0, match.start())
if booth_start >= 0:
next_booth = markup.find('<div class="booth"', match.end())
section_end = markup.find("</section>", match.end())
end_candidates = [pos for pos in (next_booth, section_end) if pos >= 0]
booth_end = min(end_candidates) if end_candidates else min(len(markup), match.end() + 2500)
booth = markup[booth_start:booth_end]
if not row_contains_other_resume(booth, rno):
return booth
start = max(0, match.start() - 300)
end = min(len(markup), match.end() + 1200)
return markup[start:end]
def parse_with_bs4(markup: str, limit: int) -> list[Candidate] | None:
try:
from bs4 import BeautifulSoup
except ImportError:
return None
soup = BeautifulSoup(markup, "html.parser")
candidates: list[Candidate] = []
seen: set[str] = set()
for link in soup.select('a[href*="/corp/person/find/resume/view?rNo="]'):
raw_href = link.get("href", "")
href = raw_href if isinstance(raw_href, str) else ""
matched_rno = re.search(r"rNo=(\d+)", href)
if not matched_rno:
continue
rno = matched_rno.group(1)
if rno in seen:
continue
seen.add(rno)
container = (
link.find_parent("tr", attrs={"data-rno": rno})
or link.find_parent(class_=re.compile(r"(^|\s)booth(\s|$)", re.I))
or link.parent
)
if container and row_contains_other_resume(str(container), rno):
container = link.parent
raw = clean_text(str(container)) if container else clean_text(str(link))
texts = []
for node in container.find_all(["dt", "dd", "p", "span", "li"]) if container else []:
label = node.get_text(" ", strip=True)
if label and not is_action_control_label(label):
texts.append(label)
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
label = btn.get_text(" ", strip=True)
if label and not is_action_control_label(label):
texts.append(label)
text_join = " | ".join(dict.fromkeys(texts))
name_scope = container.select_one(".nameAge") if container else None
dt = (name_scope or container).find("dt") if container else None
name = dt.get_text(" ", strip=True) if dt else ""
dd = dt.find_next("dd") if dt else None
meta = dd.get_text(" ", strip=True) if dd else ""
if not name:
m_name = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
if m_name:
name = m_name.group(1)
meta = "(" + m_name.group(2) + ")"
skills = []
for btn in container.select(".keywordSkill button, .keywordBox button") if container else []:
label = btn.get_text(" ", strip=True)
if label and not is_action_control_label(label):
skills.append(label)
career_node = container.select_one(".career") if container else None
candidates.append(
Candidate(
rno=rno,
url=urllib.parse.urljoin(BASE_URL, href),
name=name,
meta=meta,
career=career_node.get_text(" ", strip=True) if career_node else "",
skills=", ".join(skills[:25]),
raw_summary=filter_action_control_text(text_join[:1000] or raw[:1000]),
)
)
if len(candidates) >= limit:
break
return candidates
def parse_with_regex(markup: str, limit: int) -> list[Candidate]:
candidates: list[Candidate] = []
seen: set[str] = set()
for match in RESUME_LINK_RE.finditer(markup):
rno = match.group("rno")
if rno in seen:
continue
seen.add(rno)
raw_markup = extract_regex_candidate_markup(markup, match, rno)
raw = clean_text(raw_markup)
name = ""
meta = ""
name_match = re.search(r"([가-힣A-Za-z]OO)\s*\(([^)]*)\)", raw)
if name_match:
name = name_match.group(1)
meta = "(" + name_match.group(2) + ")"
candidates.append(
Candidate(
rno=rno,
url=urllib.parse.urljoin(BASE_URL, match.group("href")),
name=name,
meta=meta,
raw_summary=filter_action_control_text(raw[:1000]),
)
)
if len(candidates) >= limit:
break
return candidates
def parse_candidates(markup: str, limit: int) -> list[Candidate]:
parsed = parse_with_bs4(markup, limit)
if parsed is not None:
return parsed
return parse_with_regex(markup, limit)

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Search public JobKorea talent summaries.
This helper uses JobKorea's browser-visible corporate talent search page and its
same AJAX endpoint. It only reads public/obfuscated list summaries. Full resume
view, contact details, scraping at scale, scrap/bookmark, and position proposal
flows are intentionally out of scope because they require an employer account,
paid entitlements, or user confirmation.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
from dataclasses import asdict
from jobkorea_talent_models import Candidate
from jobkorea_talent_parse import clean_text, parse_candidates
from jobkorea_talent_search_condition import build_search_condition, post_search
__all__ = ["parse_candidates"]
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Search public JobKorea talent summaries")
parser.add_argument("--keyword", "-k", action="append", default=[], help="통합검색 키워드. 여러 번 지정 가능")
parser.add_argument("--and-keyword", action="append", default=[], help="AND 키워드")
parser.add_argument("--or-keyword", action="append", default=[], help="OR 키워드")
parser.add_argument("--exclude-keyword", action="append", default=[], help="제외 키워드")
parser.add_argument("--job-category", action="append", default=[], help="직무 대분류명 예: AI·개발·데이터")
parser.add_argument("--work-area", action="append", default=[], help="희망 근무지역 예: 서울, 강남구, 경기")
parser.add_argument("--residential-area", action="append", default=[], help="거주지역 예: 서울, 성남시 분당구")
parser.add_argument("--career-min", type=int, help="최소 경력 연수")
parser.add_argument("--career-max", type=int, help="최대 경력 연수")
parser.add_argument("--page", type=int, default=1)
parser.add_argument("--limit", type=int, default=20, choices=[10, 20, 30, 50, 100])
parser.add_argument("--sort", default="0", help="잡코리아 sf 정렬 코드. 기본 0")
parser.add_argument("--json", action="store_true", help="JSON으로 출력")
return parser
def print_markdown(candidates: list[Candidate], matched: dict[str, list[str]], args: argparse.Namespace) -> None:
print("# 잡코리아 인재검색 결과\n")
print(f"- 검색어: {', '.join(args.keyword + args.and_keyword + args.or_keyword) or '(없음)'}")
print(f"- 제외어: {', '.join(args.exclude_keyword) or '(없음)'}")
if any(matched.values()):
print(f"- 매칭된 필터: {json.dumps(matched, ensure_ascii=False)}")
print(f"- 결과 수: {len(candidates)}")
print("- 주의: 이름/회사명은 잡코리아 공개 화면 기준으로 마스킹되어 있으며, 상세 이력서 확인·포지션 제안은 기업회원 로그인/권한/사용자 확인이 필요합니다.\n")
for idx, candidate in enumerate(candidates, 1):
c = candidate
bits = [c.name, c.meta, c.career]
title = " ".join(x for x in bits if x).strip() or f"rNo={c.rno}"
print(f"## {idx}. {title}")
print(f"- URL: {c.url}")
if c.skills:
print(f"- 키워드/스킬: {c.skills}")
summary = c.raw_summary.replace("\n", " ")
if summary:
print(f"- 요약: {summary[:500]}")
print()
def run(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not (args.keyword or args.and_keyword or args.or_keyword or args.job_category or args.work_area or args.residential_area):
parser.error("최소 하나 이상의 --keyword, --job-category, --work-area 등을 지정하세요")
sc, matched = build_search_condition(args)
markup = post_search(sc)
cleaned = clean_text(markup)
if "로그인" in cleaned[:500] and "인재" not in cleaned[:2000]:
raise RuntimeError("잡코리아가 로그인/차단 화면을 반환했습니다")
candidates = parse_candidates(markup, args.limit)
if args.json:
print(json.dumps({"matched_filters": matched, "candidates": [asdict(c) for c in candidates]}, ensure_ascii=False, indent=2))
else:
print_markdown(candidates, matched, args)
return 0
if __name__ == "__main__":
try:
raise SystemExit(run())
except urllib.error.HTTPError as exc:
print(f"HTTP error: {exc.code} {exc.reason}", file=sys.stderr)
raise SystemExit(2)
except (RuntimeError, urllib.error.URLError) as exc:
print(f"error: {exc}", file=sys.stderr)
raise SystemExit(1)

View file

@ -0,0 +1,136 @@
from __future__ import annotations
import argparse
import json
import urllib.parse
import urllib.request
from collections.abc import Iterator
from typing import Any
from jobkorea_talent_models import AJAX_PATH, BASE_URL, DEFAULT_UA, FIND_PATH
def fetch(url: str, *, data: bytes | None = None, headers: dict[str, str] | None = None) -> str:
req_headers = {"User-Agent": DEFAULT_UA, "Referer": BASE_URL + FIND_PATH}
if headers:
req_headers.update(headers)
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST" if data else "GET")
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", "ignore")
def extract_json_object(source: str, marker: str) -> dict[str, Any]:
idx = source.find(marker)
if idx < 0:
raise RuntimeError(f"cannot find marker: {marker}")
start = source.find("{", idx)
if start < 0:
raise RuntimeError("cannot find JSON object start")
depth = 0
in_string = False
escape = False
for pos in range(start, len(source)):
ch = source[pos]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
loaded = json.loads(source[start : pos + 1])
if not isinstance(loaded, dict):
raise RuntimeError("search condition was not a JSON object")
return loaded
raise RuntimeError("unterminated JSON object")
def iter_nodes(node: Any) -> Iterator[dict[str, Any]]:
if isinstance(node, dict):
yield node
for value in node.values():
yield from iter_nodes(value)
elif isinstance(node, list):
for item in node:
yield from iter_nodes(item)
def mark_matching_nodes(sc: dict[str, Any], top_key: str, labels: list[str]) -> list[str]:
if not labels:
return []
section = sc.get(top_key)
if section is None:
return []
wanted = [x.strip().lower() for x in labels if x.strip()]
matched: list[str] = []
for node in iter_nodes(section):
title = str(node.get("t", ""))
code = str(node.get("v", ""))
title_l = title.lower()
code_l = code.lower()
if any(w == title_l or w == code_l or w in title_l for w in wanted):
for key in ("s", "c", "use"):
if key in node:
node[key] = 1
matched.append(title or code)
return matched
def build_search_condition(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, list[str]]]:
first = fetch(BASE_URL + FIND_PATH)
sc = extract_json_object(first, "var searchcondition =")
sc["p"] = args.page
sc["ps"] = args.limit
sc["saveno"] = 0
sc["ff"] = 0
sc["sf"] = args.sort
terms: list[dict[str, Any]] = []
for kw in args.keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 0})
for kw in args.and_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 1})
for kw in args.or_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 3})
for kw in args.exclude_keyword:
terms.append({"s": 1, "c": 1, "t": kw, "v": kw, "kwdtypecode": 1, "logictypecode": 2})
sc["totalkeywordlist"] = terms
if terms:
first_kw = terms[0]["t"]
sc.setdefault("pfr", {}).setdefault("ck", {})["Keyword"] = first_kw
sc["pfr"]["ck"]["KeywordType"] = 1
sc["pfr"]["n"] = 1
if args.career_min is not None:
sc.setdefault("career", {})["s"] = str(args.career_min)
if args.career_max is not None:
sc.setdefault("career", {})["e"] = str(args.career_max)
matched = {
"job_category": mark_matching_nodes(sc, "jobtype", args.job_category),
"work_area": mark_matching_nodes(sc, "workarea", args.work_area),
"residential_area": mark_matching_nodes(sc, "residentialarea", args.residential_area),
}
return sc, matched
def post_search(sc: dict[str, Any]) -> str:
body = urllib.parse.urlencode({"searchCondition": json.dumps(sc, ensure_ascii=False)}).encode()
return fetch(
BASE_URL + AJAX_PATH,
data=body,
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
)

View file

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Fixture tests for JobKorea public fallback parsing."""
from __future__ import annotations
import importlib.util
import sys
import unittest
from pathlib import Path
SCRIPT = Path(__file__).with_name("jobkorea_talent_search.py")
sys.path.insert(0, str(SCRIPT.parent))
spec = importlib.util.spec_from_file_location("jobkorea_talent_search", SCRIPT)
assert spec is not None
helper = importlib.util.module_from_spec(spec)
sys.modules["jobkorea_talent_search"] = helper
assert spec.loader is not None
spec.loader.exec_module(helper)
FALLBACK_FIXTURE = """
<section class="searchList">
<table class="tblSearchList">
<tbody>
<tr class="dvResumeTr" data-rno="111">
<td class="tdProfile">
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">김OO</a></dt><dd>(, 29)</dd></dl>
<ul class="bullList"><li>25분전 공고 스크랩</li></ul>
</td>
<td class="tdSummary">
<div class="userInfoBox">
<span class="career">경력 4</span>
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=111" data-rno="111">퍼포먼스 마케터</a></p>
<div class="keywordSkill keywordBox">
<button type="button" class="js-kwrdSearch">Google Analytics</button>
<button type="button" class="js-kwrdSearch">GA4</button>
</div>
</div>
</td>
<td class="tdAction">
<button>스크랩 1</button><button>이력서 확인</button><button>포지션 제안</button><button>메모하기</button><button>저장하기</button><button>닫기</button>
</td>
</tr>
<tr class="dvResumeTr" data-rno="222">
<td class="tdProfile">
<dl class="nameAge"><dt><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">박OO</a></dt><dd>(, 31)</dd></dl>
</td>
<td class="tdSummary">
<span class="career">경력 6</span>
<p class="title"><a class="dvResumeLink" href="/corp/person/find/resume/view?rNo=222" data-rno="222">브랜드 마케터</a></p>
<div class="keywordSkill keywordBox"><button type="button" class="js-kwrdSearch">브랜딩</button></div>
</td>
</tr>
</tbody>
</table>
</section>
"""
class JobKoreaFallbackParserTest(unittest.TestCase):
def test_parser_keeps_each_candidate_inside_its_own_row(self) -> None:
candidates = helper.parse_candidates(FALLBACK_FIXTURE, 10)
self.assertEqual([c.rno for c in candidates], ["111", "222"])
self.assertEqual(candidates[0].name, "김OO")
self.assertIn("Google Analytics", candidates[0].raw_summary)
self.assertIn("GA4", candidates[0].raw_summary)
self.assertNotIn("박OO", candidates[0].raw_summary)
self.assertNotIn("브랜딩", candidates[0].raw_summary)
self.assertNotIn("저장하기", candidates[0].raw_summary)
self.assertNotIn("닫기", candidates[0].raw_summary)
self.assertNotIn("포지션 제안", candidates[0].raw_summary)
self.assertNotIn("이력서 확인", candidates[0].raw_summary)
if __name__ == "__main__":
unittest.main()

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`)

View file

@ -1,223 +1,199 @@
---
name: kakaotalk-mac
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
description: Search local KakaoTalk archives on Apple Silicon macOS through the katok CLI.
license: MIT
metadata:
category: messaging
locale: ko-KR
phase: v1.5
phase: v2
---
# KakaoTalk Mac CLI
# KakaoTalk katok Search
## What this skill does
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
`katok` CLI를 유일한 실행 표면으로 사용해 macOS 카카오톡 대화를 로컬 아카이브와 검색 인덱스로 동기화하고, keyword/BM25/semantic 검색과 chunk 조회를 수행한다.
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
이 스킬은 기존 `kakaotalk-mac` 설치 경로를 유지하지만 내부 동작은 `katok` 기반이다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 이 스킬의 범위가 아니다.
## Privacy Rules
- Do not inspect local database internals from this skill.
- Do not directly read KakaoTalk DB files.
- Do not handle auth caches or decryption material.
- Use `katok sync --source macos --json` for live macOS KakaoTalk ingestion.
- Search commands should return snippets and chunk ids first.
- Retrieve full chunk content only when the user explicitly asks to open a result or provides a chunk id.
## When to use
- "카카오톡 최근 대화 목록 보여줘"
- "특정 채팅방 최근 메시지 찾아줘"
- "카카오톡 메시지 검색해줘"
- "내 카톡으로 테스트 메시지 보내줘"
- "답장 초안은 만들되 실제 전송 전에는 꼭 확인받아"
- "카카오톡에서 특정 키워드 검색해줘"
- "카톡에서 지난 회의/계약/약속 이야기 찾아줘"
- "이 검색 결과 chunk를 열어줘"
- "최근 대화가 반영됐는지 확인하고 검색해줘"
## When not to use
- macOS가 아닌 환경
- 카카오톡 Mac 앱이 설치되어 있지 않은 환경
- 사용자 확인 없이 다른 사람에게 메시지를 바로 보내야 하는 작업
- 카카오 공식 API 범위 안에서 해결 가능한 서버-투-서버 연동 작업
- Intel Mac에서 로컬 EmbeddingGemma semantic index가 필요한 경우
- 카카오톡 메시지를 보내거나 삭제해야 하는 경우
- 카카오톡 DB 파일, 인증 캐시, 복호화 material을 직접 다루라는 요청
- 서버-투-서버 공식 Kakao API 연동 요청
## Prerequisites
- macOS
- Apple Silicon macOS
- KakaoTalk for Mac 설치
- Homebrew
- Mac App Store 로그인(`mas` 사용 시)
- `kakaocli` 설치
- `python3` 3.10+
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
- 터미널 앱에 **Full Disk Access****Accessibility** 권한 부여
- Homebrew 또는 Cargo
- `katok` CLI 설치
- 현재 터미널 앱에 Full Disk Access 권한
## Inputs
## Install katok
- 채팅방 이름 또는 검색 키워드
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d`
- 전송할 메시지 본문
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부 (`--me`, `--dry-run`)
Homebrew:
```bash
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
brew install katok
```
Cargo:
```bash
cargo install katok
export PATH="$HOME/.cargo/bin:$PATH"
```
설치 후 CLI가 보이는지 확인한다.
```bash
katok --help
katok doctor --json
```
## Workflow
### 0. Install KakaoTalk for Mac first when missing
카카오톡 Mac 앱이 없으면 먼저 설치한다. `mas` 를 쓰려면 App Store 로그인 상태여야 한다.
### 1. Check readiness without prompting for app data
```bash
brew install mas
mas account
mas install 869223134
katok doctor --json
```
`mas install` 이 막히면 App Store 앱에서 먼저 로그인한 뒤 다시 시도한다.
`doctor --json`의 `freshness` 섹션에서 마지막 sync/index 상태를 확인한다. 이 기본 doctor는 macOS app-data probe를 실행하지 않으므로 권한 prompt를 띄우지 않는 준비 상태 점검에 적합하다.
### 1. Install `kakaocli`
### 2. Open macOS permission settings when needed
공식 저장소 기준 권장 설치는 Homebrew tap 이다.
Full Disk Access 설정이 필요하면 사용자가 직접 허용할 수 있도록 설정 화면을 연다.
```bash
brew install silver-flight-group/tap/kakaocli
katok permissions macos
```
설치 후 바로 상태를 확인한다.
KakaoTalk UI 자동화는 이 스킬 범위가 아니지만, upstream 진단을 위해 Accessibility 설정 화면까지 열어야 하는 경우에만 다음 명령을 쓴다.
```bash
kakaocli status
katok permissions macos --accessibility
```
### 2. Grant the required macOS permissions
### 3. Run explicit macOS setup diagnostics only when needed
**System Settings > Privacy & Security** 에서 현재 사용하는 터미널 앱(iTerm, Terminal, Warp 등)에 아래 권한을 준다.
- **Full Disk Access**: 카카오톡 로컬 데이터베이스 읽기용
- **Accessibility**: 메시지 전송, harvest, inspect 같은 UI 자동화용
기본 규칙:
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
### 3. Verify read access before attempting side effects
먼저 읽기 경로가 되는지 확인한다.
카카오톡 앱 설치, container, DB 파일 접근 같은 macOS source adapter 상태를 확인해야 할 때만 probe를 실행한다. 이 명령은 macOS가 app-data 접근 prompt를 띄울 수 있다.
```bash
kakaocli status
kakaocli auth
kakaocli chats --limit 10 --json
katok doctor --macos-probe --json
```
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
### 4. Sync local KakaoTalk archives
### 3.5. Use the helper when `kakaocli auth` fails on `user_id` auto-detection
실제 macOS 환경에서는 `KakaoTalk.db` 라는 literal 파일이 없어도, container 안의 **78자 hex 파일**이 실제 SQLCipher DB 인 경우가 있다. 이때 `kakaocli status` 는 정상인데 `kakaocli auth` 만 실패하는 대표 원인은:
- `AlertKakaoIDsList` 후보로는 복호화가 안 됨
- plist 의 `DESIGNATEDFRIENDSREVISION:<sha512(user_id)>` 만 남아 있음
- upstream `kakaocli` 의 기본 `user_id` brute-force 시간이 짧아서 auto-detection 이 실패함
이 저장소는 그 구간만 보완하는 **read-only helper** 를 함께 제공한다.
최신 대화가 중요하거나 `freshness.recommendation.sync_before_search`가 true이면 검색 전에 sync한다.
```bash
python3 scripts/kakaotalk_mac.py auth --refresh
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
python3 scripts/kakaotalk_mac.py search "회의" --json
katok sync --source macos --json
```
- helper 는 plist 의 `AlertKakaoIDsList``DESIGNATEDFRIENDSREVISION` hash 를 읽는다.
- 후보 `user_id` 가 모두 실패하면 SHA-512 preimage search 로 실제 `user_id` 를 더 오래 찾는다.
- 성공한 `user_id`, DB 경로, derived key 는 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령(`chats`, `messages`, `search`, `schema`)은 cached `--db` / `--key` 를 붙여 `kakaocli` 를 다시 호출한다.
### 4. Read or search messages
설정 파일의 기본 source adapter를 쓰는 경우:
```bash
kakaocli messages --chat "지수" --since 1h --json
kakaocli search "점심" --json
katok sync --json
```
helper 경유 예시:
### 5. Build or refresh the semantic index
semantic search 전 `freshness.recommendation.index_before_semantic_search`가 true이거나 방금 sync한 내용을 semantic 검색에 반영해야 하면 index를 만든다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
python3 scripts/kakaotalk_mac.py search "점심" --json
katok index --json
```
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
`katok index`는 기본적으로 로컬 `embeddinggemma-300m-q4` embedder를 사용한다. Python, Jina, TEI, 별도 HTTP embedding server를 요구하지 않는다.
### 5. Use safe testing before real sends
### 6. Search with the narrowest useful mode
실제 전송 전에 먼저 자기 자신에게 테스트하거나 dry-run 으로 확인한다.
정확한 문자열, 이름, 계좌번호, 고유명사는 keyword search를 먼저 쓴다.
```bash
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "채팅방 이름" "보낼 문장"
katok search keyword "검색어" --json
```
`--me` 는 나와의 채팅으로 보내므로 가장 안전한 테스트 경로다.
### 6. Confirm before sending to other people
다른 사람이나 단체방으로 보내기 전에는 반드시 사용자의 최종 확인을 받는다.
확인 전에는 아래만 준비한다.
- 대상 채팅방 이름
- 전송할 문장
- 왜 이 문장을 보내는지 한 줄 설명
확인을 받았을 때만 전송한다.
여러 단어가 섞인 일반 질의는 BM25를 쓴다.
```bash
kakaocli send "채팅방 이름" "보낼 문장"
katok search bm25 "지난주 미팅 자료" --json
```
### 7. Delete a sent message only with explicit operator intent
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
표현이 정확히 기억나지 않는 의미 기반 질의는 semantic search를 쓴다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --dry-run
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --everyone
katok search semantic "최근에 논의한 세금 신고 일정" --json
```
주의:
### 7. Retrieve explicit chunks only when needed
- helper의 `chats`, `messages`, `search`, `schema` 는 read-only 경로다. `delete` / `delete-last` 는 UI side effect 이므로 Accessibility 권한과 명시적 실행 의도가 필요하다.
- 메시지 ID는 로컬 DB의 `messages --json` 출력 기준이며 UI에서 동일한 DB row를 직접 증명할 수 있다는 뜻은 아니다. 실행 계약은 선택된 outbound DB 메시지의 정규화된 텍스트가 현재 활성 채팅방 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 삭제하는 것이다.
- 대상 메시지 텍스트가 비어 있거나 첨부/비텍스트 메시지이거나, 정규화 후 같은 텍스트가 여러 개 있거나, 대상 bubble 이 보이지 않거나, 활성 채팅방/삭제 범위/최종 확인 버튼을 확인할 수 없으면 삭제 자동화는 실패한다.
### 8. Use login storage only when the user explicitly wants auto-login
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
검색 결과는 먼저 snippet과 chunk id 중심으로 요약한다. 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 원문 chunk를 조회한다.
```bash
kakaocli login
kakaocli login --status
katok chunk get <chunk-id> --json
katok chunk context <chunk-id> --json
katok chunk parent <chunk-id> --json
```
비밀번호를 채팅창에 보내라고 요구하지 않는다. 사용자가 직접 로컬 터미널에서 입력하게 한다.
- `katok chunk get <chunk-id> --json`: 해당 chunk 원문 조회
- `katok chunk context <chunk-id> --json`: 같은 채팅방의 직전/직후 micro chunk 조회
- `katok chunk parent <chunk-id> --json`: semantic search parent window 조회
## Synthetic QA only
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 fixture source와 deterministic embedder를 사용한다.
```bash
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
KATOK_EMBEDDER=local-test katok index --json
KATOK_EMBEDDER=mock katok index --json
```
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
## Done when
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
- readiness 요청이면 `katok doctor --json` 결과와 freshness 권장사항을 요약했다.
- 최신 검색 요청이면 필요한 경우 `katok sync --source macos --json``katok index --json` 실행 여부를 명확히 했다.
- 검색 요청이면 keyword/BM25/semantic 중 선택한 이유와 JSON 검색 결과 요약을 제공했다.
- chunk 조회 요청이면 사용자가 지정한 chunk id에 대해서만 `katok chunk get/context/parent` 결과를 요약했다.
## Failure modes
- `katok` 미설치 또는 Cargo binary PATH 누락
- Apple Silicon macOS가 아님
- KakaoTalk for Mac 미설치
- App Store 로그인 누락으로 `mas install` 실패
- Full Disk Access 미부여
- Accessibility 미부여
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
- `katok doctor --macos-probe --json`에서 container 또는 DB 파일 접근 실패
- sync 전이라 local archive가 비어 있음
- semantic index가 오래되었거나 아직 생성되지 않음
- 검색 결과가 snippet/chunk id만으로 충분하지 않아 명시적 chunk 조회가 필요함
## Notes
- 이 스킬은 macOS 전용이다.
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
- 첫 검증은 `kakaocli status``kakaocli auth` 부터 시작하는 편이 안전하다.
- `kakaocli auth``User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
- 이 스킬은 read/search/retrieve 전용이다.
- 메시지 전송과 삭제는 지원하지 않는다.
- DB 내부 구조, auth cache, decryption material은 직접 다루지 않는다.
- 기존 설치 이름은 `kakaotalk-mac`이지만 실행 표면은 `katok`이다.

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
---
name: korean-humanizer
description: AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.
description: 'AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.'
license: MIT
metadata:
category: writing

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

@ -0,0 +1,74 @@
---
name: localdata-business-status
description: 지방행정 인허가데이터(LOCALDATA)로 동네 사업장(식당·카페·숙박·약국·미용실·학원 등 인허가 업종 208종)의 영업/휴업/폐업 상태, 인허가일자(업력), 폐업일자, 업태, 주소를 조회한다. 상호+시군구로 검색하며 인증키 불필요.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 지방행정 인허가 영업상태 조회 (동네 사업장)
## What this skill does
행정안전부 **지방행정 인허가데이터(LOCALDATA)**의 지역별 CSV를 `file.localdata.go.kr`에서 직접 받아, 동네 사업장의 영업상태를 조회한다.
- 영업상태(영업/휴업/폐업), 상세영업상태, 인허가일자(업력), 폐업일자, 업태구분, 도로명/지번 주소, 데이터갱신시점
- 인허가 업종 **208종 전체** 지원 — 한글명("약국", "숙박업", "일반음식점")으로 지정 가능
전국 통파일이 업종당 수백 MB라 **시군구 단위 지역 지정**(`--region`)이 필요하다. 받은 파일은 1일 로컬 캐시한다.
이 자료에는 **사업자등록번호가 수록되지 않는다.** 상호(사업장명) 문자열 일치 후보의 사실만 나열하며, 동명 상호 가능성은 사용자가 판단한다. 자료는 매일 갱신되며 2일 전 기준으로 현행화된다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. 조회된 사실 + 출처 + 조회시각만 담는다.
- 인증 없이 동작하는 공개 파일 서버이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다.
## When to use
- "제주시 ○○호텔 지금 영업 중이야? 오래된 곳이야?" — 사업자번호를 몰라도 상호+시군구로 조회
- "이 동네 가게 폐업했어?", "이 식당 인허가가 언제야(업력)?"
## Prerequisites
- 인터넷 연결, `python3` (stdlib만 사용 — 추가 의존성 없음)
- `scripts/localdata_business_status.py` helper
- `data/localdata_industries.json`(업종 208종), `data/localdata_orgcodes.json`(지자체 245종)
## Credential requirements
- 없음. 무인증 공개 파일 다운로드다.
## Inputs
- `--name`: 상호(사업장명) — 필수
- `--region`: 시군구 — 필수 (예: `제주제주시`, `서울종로구`, `경기수원시`)
- `--industry`: 업종 slug 또는 한글명 (여러 번 지정 가능). 생략 시 일반음식점·휴게음식점·숙박업
## Privacy boundary
- 입력한 상호·지역은 LOCALDATA 파일 서버로 전송된다(다운로드 요청 파라미터).
- 자료에 사업자등록번호가 없어 상호 문자열 매칭이며 동일성을 단정하지 않는다.
## CLI examples
```bash
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "호텔샬롬" --region 제주제주시 --industry 숙박업
# 업종 여러 개
python3 localdata-business-status/scripts/localdata_business_status.py \
--name "○○약국" --region 서울종로구 --industry 약국
```
## Failure modes
- `unavailable` + 안내: 상호/지역 미입력, 지역·업종 특정 실패(후보 나열), 다운로드 실패 — 수동 확인 URL 제공.
- 0건: 매치 없음 (`total_match_count: 0`).
## Official surfaces
- 인허가 영업상태: `https://file.localdata.go.kr/file/download/<업종slug>/info?orgCode=<지자체코드>` (무인증, Referer 필요, CP949 CSV)
- 본체: <https://www.localdata.go.kr>

View file

@ -0,0 +1,210 @@
{
"affiliated_medical_institutions": "건강_부속의료기관",
"air_pollution_facility_installation": "자원환경_대기오염물질배출시설설치사업장",
"amusement_facilities_other": "문화_테마파크업(기타)",
"animal_boarding": "동물_동물위탁관리업",
"animal_breeding": "동물_동물생산업",
"animal_cremation": "동물_동물장묘업",
"animal_exhibition": "동물_동물전시업",
"animal_hospitals": "동물_동물병원",
"animal_import": "동물_동물수입업",
"animal_pharmacies": "동물_동물약국",
"animal_sales": "동물_동물판매업",
"animal_transport": "동물_동물운송업",
"artificial_insemination_centers": "동물_가축인공수정소",
"auto_campgrounds": "문화_자동차야영장업",
"bakeries": "식품_제과점영업",
"barber_shops": "생활_이용업",
"beauty_salons": "생활_미용업",
"bicycle_parking_info": "자전거보관소정보",
"billiard_halls": "생활_당구장업",
"breeding_stock_businesses": "동물_종축업",
"briquette_manufacturers": "자원환경_석연탄제조업",
"building_sanitation": "자원환경_건물위생관리업",
"car_wash_info": "세차장정보",
"caregiver_training": "기타_요양보호사교육기관",
"cctv_info": "CCTV정보",
"city_gas_companies": "자원환경_일반도시가스업체",
"city_tour_businesses": "문화_시내순환관광업",
"civil_defense_shelter_info": "민방위대피시설",
"civil_defense_water_facilities": "기타_민방위급수시설",
"clinics": "건강_의원",
"comprehensive_amusement_facilities": "문화_종합테마파크업",
"comprehensive_resorts": "문화_종합휴양업",
"comprehensive_sports_facilities": "생활_종합체육시설업",
"comprehensive_travel_agencies": "문화_종합여행업",
"construction_waste_disposal": "자원환경_건설폐기물처리업",
"container_packaging_manufacturers": "식품_용기및포장지제조업",
"container_refrigeration_equipment": "식품_용기냉동기특정설비",
"contract_catering": "식품_위탁급식영업",
"cultural_art_corporations": "문화_문화예술법인",
"dance_academies": "생활_무도학원업",
"dance_halls": "생활_무도장업",
"dental_labs": "건강_치과기공소",
"disinfection_companies": "자원환경_소독업",
"distribution_specialty_retailers": "식품_유통전문판매업",
"domestic_international_travel_agencies": "문화_국내외여행업",
"domestic_travel_agencies": "문화_국내여행업",
"door_to_door_sales": "생활_방문판매업",
"dust_emission_business_info": "비산먼지발생사업정보",
"ecommerce_businesses": "생활_통신판매업",
"edible_ice_retailers": "식품_식용얼음판매업",
"elevator_maintenance": "기타_승강기유지관리업체",
"elevator_manufacturers_importers": "기타_승강기제조및수입업체",
"emergency_call_box_info": "안전비상벨위치정보",
"emergency_patient_transport": "건강_응급환자이송업",
"emission_inspection_agencies": "자원환경_배출가스전문정비사업자(확인검사대행자)",
"entertainment_bars": "식품_유흥주점영업",
"environment_consulting_companies": "자원환경_환경컨설팅회사",
"environment_contractors": "자원환경_환경전문공사업",
"environment_management_agencies": "자원환경_환경관리대행기관",
"environment_measurement_agencies": "자원환경_환경측정대행업",
"excellent_restaurant_info": "모범음식점정보",
"feed_manufacturers": "동물_사료제조업",
"film_distributors": "문화_영화배급업",
"film_importers": "문화_영화수입업",
"film_producers": "문화_영화제작업",
"film_screenings": "문화_영화상영업",
"fishing_spot_info": "낚시터정보",
"fitness_centers": "생활_체력단련장업",
"food_additive_manufacturers": "식품_식품첨가물제조업",
"food_freezing_refrigeration": "식품_식품냉동냉장업",
"food_manufacturing_processors": "식품_식품제조가공업",
"food_repackagers": "식품_식품소분업",
"food_transporters": "식품_식품운반업",
"food_vending_machines": "식품_식품자동판매기업",
"foreigner_city_homestays": "문화_외국인관광도시민박업",
"foreigners_entertainment_restaurants": "식품_외국인전용유흥음식점업",
"free_job_centers": "기타_무료직업소개소",
"free_wifi_info": "무료와이파이정보",
"funeral_director_training": "기타_장례지도사 교육기관",
"funeral_service_providers": "기타_상조업",
"game_distributors": "문화_게임물배급업",
"game_producers": "문화_게임물제작업",
"general_amusement_facilities": "문화_일반테마파크업",
"general_campgrounds": "문화_일반야영장업",
"general_game_providers": "문화_일반게임제공업",
"general_restaurants": "식품_일반음식점",
"golf_courses": "생활_골프장",
"golf_practice_ranges": "생활_골프연습장업",
"groundwater_construction": "자원환경_지하수시공업체",
"groundwater_impact_assessment": "자원환경_지하수영향조사기관",
"groundwater_remediation": "자원환경_지하수정화업체",
"group_meal_facilities": "식품_집단급식소",
"group_meal_food_retailers": "식품_집단급식소식품판매업",
"hanok_experience": "문화_한옥체험업",
"hatcheries": "동물_부화업",
"health_functional_food_general_retailers": "식품_건강기능식품일반판매업",
"health_functional_food_specialty_retailers": "식품_건강기능식품유통전문판매업",
"high_pressure_gas": "자원환경_고압가스업",
"horse_riding": "생활_승마장업",
"hospitals": "건강_병원",
"household_waste_info": "생활쓰레기배출정보",
"ice_rinks": "생활_빙상장업",
"instant_food_processors": "식품_즉석판매제조가공업",
"international_convention_facilities": "문화_국제회의시설업",
"international_convention_planners": "문화_국제회의기획업",
"international_logistics_forwarders": "기타_국제물류주선업",
"karaoke_rooms": "문화_노래연습장업",
"large_scale_retail_stores": "생활_대규모점포",
"laundries": "생활_세탁업",
"livestock_farming": "동물_가축사육업",
"livestock_processing": "식품_축산가공업",
"livestock_retail": "식품_축산판매업",
"livestock_storage": "식품_축산물보관업",
"livestock_transport": "식품_축산물운반업",
"local_culture_centers": "문화_지방문화원",
"lodgings": "문화_숙박업",
"log_production": "자원환경_원목생산업",
"logistics_warehouses": "기타_물류창고업체",
"lpg_equipment_manufacturers": "자원환경_액화석유가스용품제조업체",
"lumber_import_distribution": "자원환경_목재수입유통업",
"manure_collection_transport": "자원환경_가축분뇨수집운반업",
"manure_facility_management": "자원환경_가축분뇨배출시설관리업(사업장)",
"martial_arts_dojo": "생활_체육도장업",
"meat_packers": "식품_식육포장처리업",
"medical_corporations": "건강_의료법인",
"medical_device_repair": "건강_의료기기수리업",
"medical_device_sales_rental": "건강_의료기기판매(임대)업",
"medical_laundry": "생활_의료기관세탁물처리업",
"medical_related_businesses": "건강_의료유사업",
"milk_collection": "식품_집유업",
"mixed_game_providers": "문화_복합유통게임제공업",
"mixed_video_content_providers": "문화_복합영상물제공업",
"movie_theaters": "문화_영화상영관",
"multilevel_marketing": "생활_다단계판매업체",
"museums_and_art_galleries": "문화_박물관 및 미술관",
"music_video_distributors": "문화_음반및음악영상물배급업",
"music_video_producers": "문화_음반및음악영상물제작업",
"night_soil_collection_transport": "자원환경_분뇨수집운반업",
"oil_retailers": "자원환경_석유판매업",
"onggi_manufacturers": "식품_옹기류제조업",
"online_music_services": "문화_온라인음악서비스제공업",
"optical_shops": "건강_안경업",
"other_food_retailers": "식품_식품판매업(기타)",
"outdoor_advertising_companies": "기타_옥외광고업",
"over_the_counter_medicine_stores": "건강_안전상비의약품 판매업소",
"paid_job_centers": "기타_유료직업소개소",
"pay_as_you_throw_bag_retailers": "자원환경_쓰레기종량제봉투판매업",
"pc_bangs": "문화_인터넷컴퓨터게임시설제공업",
"performance_halls": "문화_공연장",
"pet_grooming": "동물_동물미용업",
"petroleum_alt_fuel_retailers": "자원환경_석유및석유대체연료판매업체",
"pharmacies": "건강_약국",
"pop_culture_art_planners": "문화_대중문화예술기획업",
"postpartum_care": "건강_산후조리업",
"power_design_companies": "자원환경_전력기술설계업체",
"power_supervision_companies": "자원환경_전력기술감리업체",
"printing_shops": "기타_인쇄사",
"protected_tree_info": "보호수정보",
"public_baths": "생활_목욕장업",
"public_restroom_info": "공중화장실정보",
"publishers": "기타_출판사",
"record_distributors": "문화_음반물배급업",
"record_producers": "문화_음반물제작업",
"registered_sports_facilities": "생활_등록체육시설업",
"rest_cafes": "식품_휴게음식점",
"rural_homestays": "문화_농어촌민박업",
"sawmills": "자원환경_제재업",
"septic_sewage_design_build": "자원환경_단독정화조 및 오수처리시설설계시공업",
"singing_bars": "식품_단란주점영업",
"ski_resorts": "생활_스키장",
"slaughterhouses": "동물_도축업",
"sledding": "생활_썰매장업",
"small_sewage_facility_management": "자원환경_개인하수처리시설관리업(사업장)",
"special_resorts": "문화_전문휴양업",
"specific_high_pressure_gas": "자원환경_특정고압가스업",
"speed_bump_info": "과속방지턱정보",
"sponsored_door_to_door_sales": "생활_후원방문판매업체",
"swimming_pools": "생활_수영장업",
"telemarketing_sales": "생활_전화권유판매업",
"tobacco_import_retailers": "기타_담배수입판매업체",
"tobacco_retailers": "기타_담배소매업",
"tobacco_wholesalers": "기타_담배도매업",
"tourism_businesses": "문화_관광사업자",
"tourist_accommodations": "문화_관광숙박업",
"tourist_cruises": "문화_관광유람선업",
"tourist_entertainment_restaurants": "식품_관광유흥음식점업",
"tourist_pensions": "문화_관광펜션업",
"tourist_performance_halls": "문화_관광공연장업",
"tourist_railways": "문화_관광궤도업",
"tourist_restaurants": "식품_관광식당",
"tourist_theater_entertainment": "문화_관광극장유흥업",
"traditional_temples": "문화_전통사찰",
"veterinary_drug_wholesalers": "동물_동물용의약품도매상",
"veterinary_medical_equipment_sales": "동물_동물용의료용구판매업",
"video_distributors": "문화_비디오물배급업",
"video_mini_theaters": "문화_비디오물소극장업",
"video_producers": "문화_비디오물제작업",
"video_streaming_providers": "문화_비디오물시청제공업",
"video_viewing_rooms": "문화_비디오물감상실업",
"water_pollution_source_other": "자원환경_수질오염원설치시설(기타)",
"water_supply_agents": "자원환경_급수공사대행업",
"water_tank_cleaning": "자원환경_저수조청소업",
"weighing_instrument_certification": "자원환경_계량기증명업",
"weighing_instrument_import": "자원환경_계량기수입업",
"weighing_instrument_manufacturing": "자원환경_계량기제조업",
"weighing_instrument_repair": "자원환경_계량기수리업",
"yacht_marinas": "생활_요트장업",
"youth_game_providers": "문화_청소년게임제공업"
}

View file

@ -0,0 +1,247 @@
{
"서울특별시 본청": "6110000",
"서울종로구": "3000000",
"서울중구": "3010000",
"서울용산구": "3020000",
"서울성동구": "3030000",
"서울광진구": "3040000",
"서울동대문구": "3050000",
"서울중랑구": "3060000",
"서울성북구": "3070000",
"서울강북구": "3080000",
"서울도봉구": "3090000",
"서울노원구": "3100000",
"서울은평구": "3110000",
"서울서대문구": "3120000",
"서울마포구": "3130000",
"서울양천구": "3140000",
"서울강서구": "3150000",
"서울구로구": "3160000",
"서울금천구": "3170000",
"서울영등포구": "3180000",
"서울동작구": "3190000",
"서울관악구": "3200000",
"서울서초구": "3210000",
"서울강남구": "3220000",
"서울송파구": "3230000",
"서울강동구": "3240000",
"부산광역시 본청": "6260000",
"부산중구": "3250000",
"부산서구": "3260000",
"부산동구": "3270000",
"부산영도구": "3280000",
"부산진구": "3290000",
"부산동래구": "3300000",
"부산남구": "3310000",
"부산북구": "3320000",
"부산해운대구": "3330000",
"부산사하구": "3340000",
"부산금정구": "3350000",
"부산강서구": "3360000",
"부산연제구": "3370000",
"부산수영구": "3380000",
"부산사상구": "3390000",
"부산기장군": "3400000",
"대구광역시 본청": "6270000",
"대구중구": "3410000",
"대구동구": "3420000",
"대구서구": "3430000",
"대구남구": "3440000",
"대구북구": "3450000",
"대구수성구": "3460000",
"대구달서구": "3470000",
"대구달성군": "3480000",
"대구군위군": "5141000",
"인천광역시 본청": "6280000",
"인천중구": "3490000",
"인천동구": "3500000",
"인천미추홀구": "3510500",
"인천연수구": "3520000",
"인천남동구": "3530000",
"인천부평구": "3540000",
"인천계양구": "3550000",
"인천서구": "3560000",
"인천강화군": "3570000",
"인천옹진군": "3580000",
"광주광역시 본청": "6290000",
"광주동구": "3590000",
"광주서구": "3600000",
"광주남구": "3610000",
"광주북구": "3620000",
"광주광산구": "3630000",
"대전광역시 본청": "6300000",
"대전동구": "3640000",
"대전중구": "3650000",
"대전서구": "3660000",
"대전유성구": "3670000",
"대전대덕구": "3680000",
"울산광역시 본청": "6310000",
"울산중구": "3690000",
"울산남구": "3700000",
"울산동구": "3710000",
"울산북구": "3720000",
"울산울주군": "3730000",
"세종특별자치시 본청": "5690000",
"경기도 본청": "6410000",
"경기평택시": "3910000",
"경기동두천시": "3920000",
"경기안산시": "3930000",
"경기고양시": "3940000",
"경기과천시": "3970000",
"경기구리시": "3980000",
"경기남양주시": "3990000",
"경기수원시": "3740000",
"경기성남시": "3780000",
"경기의정부시": "3820000",
"경기안양시": "3830000",
"경기부천시": "3860000",
"경기광명시": "3900000",
"경기오산시": "4000000",
"경기시흥시": "4010000",
"경기군포시": "4020000",
"경기의왕시": "4030000",
"경기하남시": "4040000",
"경기용인시": "4050000",
"경기파주시": "4060000",
"경기이천시": "4070000",
"경기안성시": "4080000",
"경기김포시": "4090000",
"경기여주시": "5700000",
"경기연천군": "4140000",
"경기가평군": "4160000",
"경기양평군": "4170000",
"경기화성시": "5530000",
"경기광주시": "5540000",
"경기양주시": "5590000",
"경기포천시": "5600000",
"강원특별자치도 본청": "6530000",
"강원춘천시": "4181000",
"강원원주시": "4191000",
"강원강릉시": "4201000",
"강원동해시": "4211000",
"강원태백시": "4221000",
"강원속초시": "4231000",
"강원삼척시": "4241000",
"강원홍천군": "4251000",
"강원횡성군": "4261000",
"강원영월군": "4271000",
"강원평창군": "4281000",
"강원정선군": "4291000",
"강원철원군": "4301000",
"강원화천군": "4311000",
"강원양구군": "4321000",
"강원인제군": "4331000",
"강원고성군": "4341000",
"강원양양군": "4351000",
"충청북도 본청": "6430000",
"충북청주시": "5710000",
"충북충주시": "4390000",
"충북제천시": "4400000",
"충북보은군": "4420000",
"충북옥천군": "4430000",
"충북영동군": "4440000",
"충북진천군": "4450000",
"충북괴산군": "4460000",
"충북음성군": "4470000",
"충북단양군": "4480000",
"충북증평군": "5570000",
"충청남도 본청": "6440000",
"충남당진시": "5680000",
"충남천안시": "4490000",
"충남공주시": "4500000",
"충남보령시": "4510000",
"충남아산시": "4520000",
"충남서산시": "4530000",
"충남논산시": "4540000",
"충남금산군": "4550000",
"충남부여군": "4570000",
"충남서천군": "4580000",
"충남청양군": "4590000",
"충남홍성군": "4600000",
"충남예산군": "4610000",
"충남태안군": "4620000",
"충남계룡시": "5580000",
"전북특별자치도 본청": "6540000",
"전북전주시": "4641000",
"전북군산시": "4671000",
"전북익산시": "4681000",
"전북정읍시": "4691000",
"전북남원시": "4701000",
"전북김제시": "4711000",
"전북완주군": "4721000",
"전북진안군": "4731000",
"전북무주군": "4741000",
"전북장수군": "4751000",
"전북임실군": "4761000",
"전북순창군": "4771000",
"전북고창군": "4781000",
"전북부안군": "4791000",
"전라남도 본청": "6460000",
"전남목포시": "4800000",
"전남여수시": "4810000",
"전남순천시": "4820000",
"전남나주시": "4830000",
"전남광양시": "4840000",
"전남담양군": "4850000",
"전남곡성군": "4860000",
"전남구례군": "4870000",
"전남고흥군": "4880000",
"전남보성군": "4890000",
"전남화순군": "4900000",
"전남장흥군": "4910000",
"전남강진군": "4920000",
"전남해남군": "4930000",
"전남영암군": "4940000",
"전남무안군": "4950000",
"전남함평군": "4960000",
"전남영광군": "4970000",
"전남장성군": "4980000",
"전남완도군": "4990000",
"전남진도군": "5000000",
"전남신안군": "5010000",
"경상북도 본청": "6470000",
"경북포항시": "5020000",
"경북경주시": "5050000",
"경북김천시": "5060000",
"경북안동시": "5070000",
"경북구미시": "5080000",
"경북영주시": "5090000",
"경북영천시": "5100000",
"경북상주시": "5110000",
"경북문경시": "5120000",
"경북경산시": "5130000",
"경북의성군": "5150000",
"경북청송군": "5160000",
"경북영양군": "5170000",
"경북영덕군": "5180000",
"경북청도군": "5190000",
"경북고령군": "5200000",
"경북성주군": "5210000",
"경북칠곡군": "5220000",
"경북예천군": "5230000",
"경북봉화군": "5240000",
"경북울진군": "5250000",
"경북울릉군": "5260000",
"경상남도 본청": "6480000",
"경남창원시": "5670000",
"경남진주시": "5310000",
"경남통영시": "5330000",
"경남사천시": "5340000",
"경남김해시": "5350000",
"경남밀양시": "5360000",
"경남거제시": "5370000",
"경남양산시": "5380000",
"경남의령군": "5390000",
"경남함안군": "5400000",
"경남창녕군": "5410000",
"경남고성군": "5420000",
"경남남해군": "5430000",
"경남하동군": "5440000",
"경남산청군": "5450000",
"경남함양군": "5460000",
"경남거창군": "5470000",
"경남합천군": "5480000",
"제주특별자치도 본청": "6500000",
"제주제주시": "6510000",
"제주서귀포시": "6520000"
}

View file

@ -0,0 +1,206 @@
"""LOCALDATA (지방행정 인허가) business operating-status lookup (unauthenticated).
행정안전부 지방행정 인허가데이터를 file.localdata.go.kr 지역별 CSV로 직접 받아
동네 사업장(식당·카페·숙박·약국 인허가 업종 208) 영업/휴업/폐업 상태를
조회한다. 인증키가 필요 없는 공개 파일 서버이므로 프록시를 거치지 않는다.
The data does NOT contain business registration numbers, so this is a trade-name
(사업장명) string match only it cannot assert identity against a given number.
전국 통파일이 업종당 수백 MB라 시군구 단위 파일을 받으려면 --region 필요하다.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import io
import json
import pathlib
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
BASE = "https://file.localdata.go.kr"
LANDING = f"{BASE}/file/general_restaurants/info"
SOURCE = ("지방행정 인허가데이터(LOCALDATA) 업종별 영업상태 — 행정안전부 "
"(file.localdata.go.kr 지역별 CSV, 매일 갱신·2일 전 기준 현행화)")
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
KST = dt.timezone(dt.timedelta(hours=9))
_DATA_DIR = pathlib.Path(__file__).resolve().parent.parent / "data"
INDUSTRIES: dict = json.loads((_DATA_DIR / "localdata_industries.json").read_text(encoding="utf-8"))
DEFAULT_INDUSTRIES = ("general_restaurants", "rest_cafes", "lodgings")
RESULT_COLUMNS = ("사업장명", "영업상태명", "상세영업상태명", "인허가일자", "폐업일자",
"업태구분명", "도로명주소", "지번주소", "데이터갱신시점")
CACHE_DIR = pathlib.Path.home() / ".cache" / "k-skill" / "localdata-business-status"
CACHE_TTL_SECONDS = 24 * 3600 # 원천이 일 단위 갱신이므로 1일 캐시
IDENTITY_NOTE = ("인허가 자료에는 사업자등록번호가 수록되지 않아 입력 사업자번호와의 "
"동일성은 확인할 수 없다 — 상호(사업장명) 문자열 일치 후보의 사실만 "
"나열하며, 동명 상호 가능성은 사용자가 판단한다. 자료는 매일 갱신되며 "
"2일 전 기준으로 현행화된다.")
def _now_iso() -> str:
return dt.datetime.now(KST).isoformat(timespec="seconds")
def _envelope(status: str, *, result: dict | None = None, note: str | None = None) -> dict:
return {
"source": SOURCE,
"looked_up_at": _now_iso(),
"status": status,
"result": result,
"origin": "unauthenticated-public",
"note": note,
}
def org_codes() -> dict:
return json.loads((_DATA_DIR / "localdata_orgcodes.json").read_text(encoding="utf-8"))
def resolve_industry(token: str) -> tuple[str | None, list[str]]:
"""업종 지정 해석 — slug 정확 일치 또는 한글명 일치. (slug, 후보들)."""
token = token.strip()
if token in INDUSTRIES:
return token, [INDUSTRIES[token]]
squeezed = token.replace(" ", "")
exact = [(slug, nm) for slug, nm in INDUSTRIES.items()
if nm.replace(" ", "") == squeezed
or nm.split("_", 1)[-1].replace(" ", "") == squeezed]
if len(exact) == 1:
return exact[0][0], [exact[0][1]]
hits = exact or [(slug, nm) for slug, nm in INDUSTRIES.items()
if squeezed in nm.replace(" ", "")]
if len(hits) == 1:
return hits[0][0], [hits[0][1]]
return None, [nm for _, nm in hits]
def _resolve_region(region: str) -> tuple[str | None, list[str]]:
table = org_codes()
region = region.strip()
if region in table:
return table[region], [region]
squeezed = region.replace(" ", "")
hits = [nm for nm in table if squeezed in nm.replace(" ", "")]
if len(hits) == 1:
return table[hits[0]], hits
return None, hits
def _fetch_csv(slug: str, org_code: str, *, opener: Any = None) -> str:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache = CACHE_DIR / f"{slug}_{org_code}.csv"
if cache.exists() and time.time() - cache.stat().st_mtime < CACHE_TTL_SECONDS:
return cache.read_text(encoding="utf-8")
params = urllib.parse.urlencode({"orgCode": org_code})
request = urllib.request.Request(
f"{BASE}/file/download/{slug}/info?{params}",
headers={"User-Agent": USER_AGENT, "Referer": LANDING},
method="GET",
)
open_fn = opener or urllib.request.urlopen
with open_fn(request, timeout=120) as response:
status = getattr(response, "status", 200)
content_type = response.headers.get("Content-Type", "") if hasattr(response, "headers") else ""
if status != 200 or "csv" not in (content_type or ""):
raise RuntimeError(f"HTTP {status} ({content_type or '?'})")
text = response.read().decode("cp949", errors="replace")
cache.write_text(text, encoding="utf-8")
return text
def _search_rows(csv_text: str, name: str) -> list[dict]:
needle = name.replace(" ", "")
out = []
for row in csv.DictReader(io.StringIO(csv_text)):
biz_name = (row.get("사업장명") or "").strip()
if needle and needle in biz_name.replace(" ", ""):
out.append({col: (row.get(col) or "").strip() for col in RESULT_COLUMNS})
return out
def lookup(name: str, region: str, industries: list[str] | None = None, *, opener: Any = None) -> dict:
"""인허가 영업상태 조회 — 상호+지역 필수 (자료에 사업자번호 없음)."""
if not (name or "").strip():
return _envelope("unavailable",
note="인허가 자료에 사업자등록번호가 수록되지 않아 상호 없이 검색할 수 "
"없습니다. --name 으로 상호를 지정하세요.")
if not (region or "").strip():
return _envelope("unavailable",
note="전국 통파일이 업종당 수백 MB라 시군구 지역 지정이 필요합니다. "
"--region 으로 지정하세요 (예: 제주제주시, 서울종로구, 경기수원시).")
name = name.strip()
code, hits = _resolve_region(region)
if code is None:
return _envelope("unavailable",
note=(f"지역 '{region}' 특정 실패 — "
+ (f"후보 {len(hits)}곳: {', '.join(hits[:8])}. 하나로 지정하세요."
if hits else "등록 지자체명과 일치하지 않습니다 (예: 서울종로구).")))
selected, bad = [], []
for token in (industries or DEFAULT_INDUSTRIES):
slug, cand = resolve_industry(token)
if slug:
selected.append(slug)
else:
bad.append(f"'{token}'" + (f" (후보 {len(cand)}종: {', '.join(cand[:6])})" if cand
else " (일치 업종 없음)"))
if bad:
return _envelope("unavailable",
note=(f"업종 특정 실패: {'; '.join(bad)}. slug 또는 한글명(예: 약국, "
"일반음식점, 숙박업)으로 하나씩 지정하세요. 총 208종 지원."))
searched, failures = {}, []
try:
for slug in selected:
try:
rows = _search_rows(_fetch_csv(slug, code, opener=opener), name)
searched[slug] = {"industry": INDUSTRIES[slug], "match_count": len(rows), "matches": rows}
except (urllib.error.URLError, RuntimeError) as err:
failures.append(f"{INDUSTRIES[slug]}({type(err).__name__})")
except Exception as err: # 경계 계약: 어떤 오류든 강등
return _envelope("unavailable", note=f"예상 외 오류({type(err).__name__}).")
if not searched:
return _envelope("unavailable",
note=f"전 업종 다운로드 실패: {', '.join(failures)}. "
f"수동 확인: https://www.localdata.go.kr")
result = {
"query": {"name": name, "region": hits[0], "org_code": code},
"industries_searched": searched,
"total_match_count": sum(v["match_count"] for v in searched.values()),
"identity_note": IDENTITY_NOTE,
}
note = (f"일부 업종 다운로드 실패: {', '.join(failures)}" if failures else None)
return _envelope("ok", result=result, note=note)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="지방행정 인허가 영업상태 조회 (무인증)")
parser.add_argument("--name", required=True, help="상호(사업장명) — 필수")
parser.add_argument("--region", required=True, help="시군구 (예: 제주제주시, 서울종로구)")
parser.add_argument("--industry", action="append", dest="industries",
help="업종 slug 또는 한글명(예: 약국, 숙박업). 여러 번 지정 가능. 생략 시 음식점·카페·숙박")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
print(json.dumps(lookup(args.name, args.region, args.industries), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,74 @@
---
name: national-pension-workplace
description: 국민연금공단 국민연금 가입 사업장 내역을 공공데이터포털 API(k-skill-proxy 경유)로 조회한다. 사업장명으로 가입자수·당월 고지금액·월별 취득/상실 추이를 확인해 그 회사의 직원 규모와 변화를 본다.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 국민연금 가입 사업장 내역 조회
## What this skill does
공공데이터포털의 **국민연금공단_국민연금 가입 사업장 내역 서비스**(data.go.kr 3046071, V2)를 `k-skill-proxy` 경유로 호출해 다음을 조회한다.
- 가입 사업장 후보: 사업장명 + 사업자번호 앞 6자리로 매칭된 사업장 목록 (자료생성년월별 중복은 사업장당 최신 월로 정리)
- 단일 사업장이 특정되면 상세: 가입자수(`jnngpCnt`), 당월 고지금액(`crrmmNtcAmt`), 신규취득/상실 인원
- 월별 가입 현황 시계열
사업자등록번호는 **앞 6자리만 공개**(뒷자리 마스킹)되므로 사업장명이 필수이며, 후보가 여럿이면 특정하지 않고 목록 그대로 돌려준다.
## Design principles
- 점수·등급·"위험" 같은 해석 라벨을 만들지 않는다. upstream이 돌려준 사실만 담는다.
- 후보가 여럿이면 동일성을 단정하지 않는다.
## When to use
- "○○ 회사 직원 규모가 얼마나 돼? 국민연금 가입자수로 보자"
- "이 사업장 당월 국민연금 고지금액이 얼마야?"
- "최근 인원이 늘었는지 줄었는지 월별로 보자"
## Prerequisites
- 인터넷 연결, `python3`
- `scripts/national_pension_workplace.py` helper
- hosted/self-host `k-skill-proxy``/v1/national-pension/workplace` route 접근 가능
## Credential requirements
- 사용자 측 필수 시크릿 없음.
- `KSKILL_PROXY_BASE_URL` — self-host 프록시를 쓸 때만 설정. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 사용.
- `DATA_GO_KR_API_KEY` 는 프록시 운영 서버 환경에만 둔다. 공공데이터포털에서 `국민연금공단_국민연금 가입 사업장 내역` 활용신청이 되어 있어야 한다.
## Inputs
- `--name`: 사업장명(상호) — 필수
- `--b-no`: 사업자등록번호(하이픈 허용). 앞 6자리만 prefix 필터로 쓰인다.
## Privacy boundary
- 국민연금 데이터는 사업자번호 앞 6자리만 공개되므로, 6자리 일치 + 상호 유사 후보를 나열할 뿐 사업장 동일성을 단정하지 않는다.
- 공개 범위는 법인·근로자 일정 규모 이상 사업장 위주이며, 소규모/개인 사업장은 미공개일 수 있다.
## CLI examples
```bash
python3 national-pension-workplace/scripts/national_pension_workplace.py \
--name "삼성전자(주)" --b-no 124-81-00998
```
## Failure modes
- `400 bad_request`: 사업장명을 주지 않음.
- `503 upstream_not_configured`: 프록시 서버에 `DATA_GO_KR_API_KEY` 없음.
- `502 upstream_forbidden`: 프록시 키가 3046071에 활용신청되지 않음.
- 후보 다수: `selected_candidate``null` — 사용자가 후보 목록에서 특정한다.
## Official surfaces
- 공공데이터포털: <https://www.data.go.kr/data/3046071/openapi.do>
- upstream: `https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2` (요청 파라미터 camelCase)
- 프록시 route: `GET /v1/national-pension/workplace`

View file

@ -0,0 +1,109 @@
"""National Pension Service workplace-coverage lookup via k-skill-proxy.
The proxy holds DATA_GO_KR_API_KEY server-side; this helper only builds the
query and reads the structured response. No user secret is required.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL"
DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org"
ROUTE = "/v1/national-pension/workplace"
class ApiError(RuntimeError):
def __init__(self, message: str, *, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _text_or_none(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def resolve_proxy_base_url(explicit: str | None = None, env: dict[str, str] | None = None) -> str:
env = os.environ if env is None else env
candidate = _text_or_none(explicit or env.get(PROXY_BASE_URL_ENV_VAR))
if candidate and candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}:
raise ValueError("KSKILL_PROXY_BASE_URL 가 비활성화되어 있습니다.")
if candidate and candidate != "replace-me":
return candidate.rstrip("/")
return DEFAULT_PROXY_BASE_URL
def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
try:
with urllib.request.urlopen(request, timeout=30) as response:
try:
payload = json.loads(response.read().decode("utf-8"))
except json.JSONDecodeError as error:
raise ApiError("national-pension proxy returned invalid JSON.") from error
if not isinstance(payload, dict):
raise ApiError("national-pension proxy returned a non-object JSON payload.")
return payload
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict) and payload.get("message"):
raise ApiError(str(payload["message"]), status_code=error.code) from error
raise ApiError(f"national-pension proxy request failed with HTTP {error.code}", status_code=error.code) from error
except urllib.error.URLError as error:
raise ApiError(f"national-pension proxy request failed: {error.reason}") from error
def query_workplace(name: str, b_no: str | None = None, *, base_url: str | None = None,
read_json: Any = read_json_response) -> dict[str, Any]:
name = _text_or_none(name)
if not name:
raise ValueError("사업장명(상호)을 입력하세요. 국민연금 API는 사업자번호 앞 6자리만 공개해 상호가 필수입니다.")
params = {"name": name}
if _text_or_none(b_no):
digits = re.sub(r"\D", "", str(b_no))
if not re.fullmatch(r"\d{10}", digits):
raise ValueError("사업자등록번호는 숫자 10자리여야 합니다 (하이픈 허용).")
params["b_no"] = digits
url = f"{resolve_proxy_base_url(base_url)}{ROUTE}?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(url, headers={
"Accept": "application/json",
"User-Agent": "k-skill-national-pension-workplace/1.0",
}, method="GET")
return read_json(request)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="국민연금 가입 사업장 내역 조회 (k-skill-proxy 경유)")
parser.add_argument("--name", required=True, help="사업장명(상호) — 필수")
parser.add_argument("--b-no", help="사업자등록번호(앞 6자리만 prefix 필터로 사용)")
parser.add_argument("--proxy-base-url")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = query_workplace(args.name, args.b_no, base_url=args.proxy_base_url)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (ValueError, ApiError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,64 @@
---
name: nts-tax-delinquency
description: 국세청 고액·상습체납자 명단공개를 nts.go.kr 공개 검색으로 조회한다. 상호·법인명으로 법인 명단과 개인 명단을 대조해 공개된 체납 사실(총 체납액·세목·체납요지 등)을 나열한다. 인증키 불필요.
license: MIT
metadata:
category: business
locale: ko-KR
phase: v1
---
# 국세청 고액·상습체납자 명단공개 검색
## What this skill does
국세청 누리집의 **고액·상습체납자 명단공개**(국세기본법 제85조의5)를 무인증 공개 검색으로 직접 조회한다.
- 법인 명단: 법인명으로 검색 — 공개년도·법인명·대표자·업종·소재지·총 체납액·세목·체납건수·체납요지
- 개인 명단: 상호로 검색 — 공개년도·성명·연령·상호·직업·주소·총 체납액·세목·체납요지
이 명단에는 **사업자등록번호가 수록되지 않는다.** 상호·법인명 문자열 일치 후보의 공개 사실만 나열하며, 동명 상호 가능성은 사용자가 판단한다.
## Design principles
- 점수·등급·해석 라벨을 만들지 않는다. 공개된 사실 + 출처만 담는다.
- 인증 없이 동작하는 공개 read-only 검색이므로 프록시를 거치지 않고 사용자 머신에서 직접 호출한다.
- HTML 스크래핑이므로 페이지 마커가 어긋나면 즉시 `unavailable`로 강등하고 수동 확인 경로를 안내한다.
## When to use
- "이 회사(거래처/의뢰인) 국세 체납 명단공개에 올라 있어?"
- "상호로 고액·상습체납자 명단 대조해줘"
## Prerequisites
- 인터넷 연결, `python3` (stdlib만 사용 — 추가 의존성 없음)
- `scripts/nts_tax_delinquency.py` helper
## Credential requirements
- 없음. 무인증 공개 검색이다.
## Inputs
- `--name`: 상호·법인명 — 필수 (명단에 사업자등록번호가 없어 번호로는 검색 불가)
## Privacy boundary
- 입력한 상호·법인명은 국세청 누리집으로 전송된다.
- 명단공개 자료에 사업자등록번호가 없어 상호·법인명 문자열 일치의 공개 사실만 나열한다.
## CLI examples
```bash
python3 nts-tax-delinquency/scripts/nts_tax_delinquency.py --name "○○건설"
```
## Failure modes
- `unavailable` + 안내: 상호 미입력, 네트워크 오류, 페이지 구조 변경 추정 — 수동 확인 URL 제공.
- 0건: 두 명단 모두 매치 없음 (`match_count: 0`).
## Official surfaces
- 명단공개 검색: `https://www.nts.go.kr/nts/ad/openInfo/selectList.do`

View file

@ -0,0 +1,150 @@
"""NTS high-amount/habitual tax-delinquent disclosure search (unauthenticated).
국세청 고액·상습체납자 명단공개를 nts.go.kr 공개 검색으로 직접 조회한다.
인증키가 필요 없는 공개 read-only endpoint이므로 프록시를 거치지 않는다.
The disclosure list does NOT contain business registration numbers, so this is a
trade-name / corporate-name string match only it cannot assert that a hit is
the same entity as a given business number.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
URL = "https://www.nts.go.kr/nts/ad/openInfo/selectList.do"
SOURCE = ("국세청 고액·상습체납자 명단공개 검색 — nts.go.kr 누리집 공개 검색 "
"(무인증, www.nts.go.kr/nts/ad/openInfo/selectList.do)")
MANUAL_NOTE = f"수동 확인: 브라우저에서 {URL} 접속 후 명단공개 검색"
USER_AGENT = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
KST = dt.timezone(dt.timedelta(hours=9))
CORP_COLUMNS = ("no", "공개년도", "법인명", "대표자", "업종", "법인소재지",
"대표자주소", "총체납액", "세목", "납기", "체납건수", "체납요지")
INDIV_COLUMNS = ("no", "공개년도", "성명", "연령", "상호", "직업(업종)", "체납자주소",
"총체납액", "세목", "납기", "체납건수", "체납요지")
IDENTITY_NOTE = ("명단공개 자료에는 사업자등록번호가 수록되지 않아 입력 사업자번호와의 "
"동일성은 확인할 수 없다 — 상호·법인명 문자열 일치 후보의 공개 사실만 "
"나열하며, 동명 상호일 가능성은 사용자가 판단한다.")
_HEADING_MARKER = "고액상습체납자"
_ZERO_MARKER = "조회된 데이터가 없습니다"
class StructureChanged(RuntimeError):
"""페이지 구조가 기대 마커와 다름 — 우아한 강등 트리거."""
def _now_iso() -> str:
return dt.datetime.now(KST).isoformat(timespec="seconds")
def _envelope(status: str, *, result: dict | None = None, note: str | None = None) -> dict:
return {
"source": SOURCE,
"looked_up_at": _now_iso(),
"status": status,
"result": result,
"origin": "unauthenticated-public",
"note": note,
}
def _strip_tags(fragment: str) -> str:
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", fragment)).strip()
def parse_rows(html: str, columns: tuple) -> list[dict]:
if _HEADING_MARKER not in html.replace(" ", ""):
raise StructureChanged("명단공개 페이지 마커(고액상습체납자) 미발견")
if _ZERO_MARKER in html:
return []
cells = [_strip_tags(td) for td in re.findall(r"<td[^>]*>(.*?)</td>", html, re.S)]
if not cells or len(cells) % len(columns) != 0:
raise StructureChanged(f"표 셀 수({len(cells)})가 컬럼 수({len(columns)})의 배수가 아님")
return [dict(zip(columns, cells[i:i + len(columns)]))
for i in range(0, len(cells), len(columns))]
def _post(data: dict[str, str], *, opener: Any = None) -> str:
request = urllib.request.Request(
URL,
data=urllib.parse.urlencode(data).encode("utf-8"),
headers={
"User-Agent": USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
},
method="POST",
)
open_fn = opener or urllib.request.urlopen
with open_fn(request, timeout=20) as response:
status = getattr(response, "status", 200)
if status != 200:
raise StructureChanged(f"HTTP {status}")
return response.read().decode("utf-8", errors="replace")
def _search(tcd: str, search_type: str, value: str, columns: tuple, *, opener: Any = None) -> list[dict]:
html = _post({
"tcd": tcd,
"searchType": search_type,
"searchValue": value,
"searchYear": "",
"currPage": "1",
"pageIndex": "100",
"search_order": "1",
}, opener=opener)
return parse_rows(html, columns)
def lookup(name: str, *, opener: Any = None) -> dict:
"""고액·상습체납자 명단공개 대조 — 법인 명단(법인명)·개인 명단(상호) 각 1회."""
if not (name or "").strip():
return _envelope("unavailable",
note=("명단공개 자료에 사업자등록번호가 수록되지 않아 상호·법인명 없이 "
f"검색할 수 없습니다. --name 으로 상호를 지정하세요. {MANUAL_NOTE}"))
name = name.strip()
try:
corp_rows = _search("1", "1", name, CORP_COLUMNS, opener=opener)
indiv_rows = _search("2", "3", name, INDIV_COLUMNS, opener=opener)
except urllib.error.URLError as err:
return _envelope("unavailable", note=f"네트워크 오류: {err.reason}. {MANUAL_NOTE}")
except StructureChanged as err:
return _envelope("unavailable", note=f"페이지 구조 변경 추정({err}). {MANUAL_NOTE}")
except Exception as err: # 경계 계약: 어떤 오류든 강등, 크래시 금지
return _envelope("unavailable", note=f"예상 외 오류({type(err).__name__}). {MANUAL_NOTE}")
result = {
"query_name": name,
"list_basis": "국세청 고액·상습체납자 명단공개 (국세기본법 제85조의5)",
"corporate_list": {"searched_by": "법인명", "match_count": len(corp_rows), "matches": corp_rows},
"individual_list": {"searched_by": "상호", "match_count": len(indiv_rows), "matches": indiv_rows},
"identity_note": IDENTITY_NOTE,
}
return _envelope("ok", result=result)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="국세청 고액·상습체납자 명단공개 검색 (무인증)")
parser.add_argument("--name", required=True, help="상호·법인명 — 필수 (명단에 사업자번호 없음)")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
print(json.dumps(lookup(args.name), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

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/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",
"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 biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.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 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 jobkorea-talent-search/scripts/jobkorea_talent_models.py jobkorea-talent-search/scripts/jobkorea_talent_parse.py jobkorea-talent-search/scripts/jobkorea_talent_search_condition.py jobkorea-talent-search/scripts/jobkorea_talent_search.py jobkorea-talent-search/scripts/test_jobkorea_talent_search.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_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",
"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_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' && PYTHONPATH=.:jobkorea-talent-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s jobkorea-talent-search/scripts -p 'test_jobkorea_talent_search.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

@ -1,5 +1,17 @@
# k-skill-proxy
## 0.7.0
### Minor Changes
- 66f12cb: 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.
## 0.6.1
### Patch Changes
- Archive unsupported Naver Map and Blue Ribbon proxy support. The proxy no longer registers `/v1/naver-map/*` or `/v1/blue-ribbon/nearby`, and the unsupported skill/package code is preserved under `legacy/` for a future revival if operational blockers are resolved.
## 0.6.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "k-skill-proxy",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"description": "Fastify proxy for k-skill upstream APIs",
"license": "MIT",
@ -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/g2b-sanction.js && node --check src/fsc-corp.js && node --check src/national-pension.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,157 @@
// Financial Services Commission (FSC) corporate-outline API wrapper.
// Proxies data.go.kr 15043184 (GetCorpBasicInfoService_V2/getCorpOutline_V2)
// and keeps the operator's DATA_GO_KR_API_KEY server-side.
//
// The upstream search parameters are crno (13-digit corporate registration
// number) and corpNm (corporate name) only — the 10-digit business number
// cannot query it directly. We search by corpNm and, when the response carries
// a bzno field, cross-check it against the supplied business number without
// asserting identity when it is absent.
const FSC_CORP_OUTLINE_URL =
"https://apis.data.go.kr/1160100/service/GetCorpBasicInfoService_V2/getCorpOutline_V2";
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function digitsOnly(value) {
return String(value ?? "").replace(/[^0-9]/g, "");
}
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
function parseGatewayAuthError(text) {
if (!text.includes("OpenAPI_ServiceResponse")) {
return null;
}
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "SERVICE ERROR";
return AUTH_REASON_CODES.has(reasonCode) ? `${authMsg} (code ${reasonCode})` : null;
}
function isAuthResultCode(code) {
return AUTH_REASON_CODES.has(String(code ?? "").trim());
}
function normalizeFscCorpQuery(query = {}) {
const corpNm = trimOrNull(query.corpNm ?? query.name ?? query.b_nm);
if (!corpNm) {
throw new Error(
"Provide corpNm (corporate name). The FSC outline API cannot be queried by the 10-digit business number alone."
);
}
const rawBno = trimOrNull(query.b_no ?? query.bno);
const bnoDigits = rawBno ? digitsOnly(rawBno) : "";
if (rawBno && !/^\d{10}$/.test(bnoDigits)) {
throw new Error("Provide b_no as a 10-digit business registration number.");
}
return { corpNm, bno: bnoDigits || null };
}
// Extracts the item list from the JSON envelope, tolerating the empty-string
// `items` variant data.go.kr returns for zero results.
function extractCorpItems(payload) {
const header = payload?.response?.header ?? {};
const resultCode = String(header.resultCode ?? "");
if (resultCode && !["00", "0"].includes(resultCode)) {
throw new Error(`resultCode=${resultCode} ${header.resultMsg ?? ""}`.trim());
}
const itemsNode = payload?.response?.body?.items;
if (!itemsNode || typeof itemsNode !== "object") {
return [];
}
let item = itemsNode.item;
if (!item) {
return [];
}
if (!Array.isArray(item)) {
item = [item];
}
return item;
}
async function fetchFscCorpOutline({ corpNm, bno = null, serviceKey, fetchImpl = global.fetch }) {
const url = new URL(FSC_CORP_OUTLINE_URL);
url.searchParams.set("serviceKey", serviceKey);
url.searchParams.set("pageNo", "1");
url.searchParams.set("numOfRows", "10");
url.searchParams.set("resultType", "json");
url.searchParams.set("corpNm", corpNm);
const doFetch = fetchImpl || global.fetch;
let response;
try {
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
} catch (err) {
return { error: "upstream_timeout", message: `Upstream request failed: ${err.message}` };
}
if (response.status === 401 || response.status === 403) {
return {
error: "upstream_forbidden",
message: `FSC upstream returned ${response.status}. The proxy key may not be approved for service 15043184.`,
};
}
if (!response.ok) {
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
}
const text = await response.text();
const gatewayAuthError = parseGatewayAuthError(text);
if (gatewayAuthError) {
return {
error: "upstream_forbidden",
message: `FSC upstream rejected the request (${gatewayAuthError}). The proxy key may not be approved for service 15043184.`,
};
}
let payload;
try {
payload = JSON.parse(text);
} catch {
return { error: "upstream_invalid_response", message: "FSC upstream did not return valid JSON." };
}
if (isAuthResultCode(payload?.response?.header?.resultCode)) {
return {
error: "upstream_forbidden",
message: `FSC upstream rejected the request (${payload.response.header.resultMsg || "auth error"}). The proxy key may not be approved for service 15043184.`,
};
}
let items;
try {
items = extractCorpItems(payload);
} catch (err) {
return { error: "upstream_error", message: `FSC upstream error response: ${err.message}` };
}
const hasBzno = items.some((it) => "bzno" in it);
const matched = hasBzno && bno ? items.filter((it) => digitsOnly(it.bzno) === bno) : [];
return {
query_corp_nm: corpNm,
candidate_count: items.length,
candidates: items,
b_no_cross_check: {
checked: Boolean(hasBzno && bno),
input_b_no: bno,
matched_candidates: matched,
},
notes:
items.length && !hasBzno
? "The response carries no business-number field, so the input number could not be cross-checked — only name-matched candidates are listed (crno is the separate corporate registration number)."
: undefined,
};
}
module.exports = {
FSC_CORP_OUTLINE_URL,
normalizeFscCorpQuery,
extractCorpItems,
fetchFscCorpOutline,
};

View file

@ -0,0 +1,134 @@
// Public Procurement Service (조달청 나라장터) sanctioned-supplier API wrapper.
// Proxies data.go.kr 15129466 (UsrInfoService02/getUnptRsttCorpInfo02) and keeps
// the operator's DATA_GO_KR_API_KEY server-side.
//
// inqryDiv=1 queries by exact 10-digit business number. The upstream returns
// only sanctions that are CURRENTLY in force at query time — expired/lifted
// sanctions and sanctions against non-registered suppliers/individuals are not
// provided. This is not a historical lookup.
const G2B_SANCTION_URL =
"https://apis.data.go.kr/1230000/ao/UsrInfoService02/getUnptRsttCorpInfo02";
function digitsOnly(value) {
return String(value ?? "").replace(/[^0-9]/g, "");
}
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
function parseGatewayAuthError(text) {
if (!text.includes("OpenAPI_ServiceResponse")) {
return null;
}
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "SERVICE ERROR";
return AUTH_REASON_CODES.has(reasonCode) ? `${authMsg} (code ${reasonCode})` : null;
}
function isAuthResultCode(code) {
return AUTH_REASON_CODES.has(String(code ?? "").trim());
}
function normalizeG2bSanctionQuery(query = {}) {
const bizno = digitsOnly(query.bizno ?? query.b_no ?? query.bno);
if (!/^\d{10}$/.test(bizno)) {
throw new Error("Provide bizno as a 10-digit business registration number.");
}
return { bizno };
}
// Extracts the item list from the JSON envelope, tolerating the dict/empty
// variants data.go.kr returns for one or zero results.
function extractSanctionItems(payload) {
const response = payload?.response ?? {};
const header = response.header ?? {};
const resultCode = String(header.resultCode ?? "");
if (resultCode && !["00", "0"].includes(resultCode)) {
throw new Error(`resultCode=${resultCode} ${header.resultMsg ?? "no message"}`.trim());
}
const body = response.body ?? {};
let items = body.items;
if (items && typeof items === "object" && !Array.isArray(items)) {
items = items.item ?? [];
}
if (!items) {
items = [];
}
if (!Array.isArray(items)) {
items = [items];
}
const totalCount = body.totalCount ?? items.length;
return { items, totalCount };
}
async function fetchG2bSanctions({ bizno, serviceKey, fetchImpl = global.fetch }) {
const url = new URL(G2B_SANCTION_URL);
url.searchParams.set("ServiceKey", serviceKey);
url.searchParams.set("numOfRows", "100");
url.searchParams.set("pageNo", "1");
url.searchParams.set("type", "json");
url.searchParams.set("inqryDiv", "1");
url.searchParams.set("bizno", bizno);
const doFetch = fetchImpl || global.fetch;
let response;
try {
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
} catch (err) {
return { error: "upstream_timeout", message: `Upstream request failed: ${err.message}` };
}
if (response.status === 401 || response.status === 403) {
return {
error: "upstream_forbidden",
message: `Procurement upstream returned ${response.status}. The proxy key may not be approved for service 15129466.`,
};
}
if (!response.ok) {
return { error: "upstream_error", message: `Upstream returned ${response.status}` };
}
const text = await response.text();
const gatewayAuthError = parseGatewayAuthError(text);
if (gatewayAuthError) {
return {
error: "upstream_forbidden",
message: `Procurement upstream rejected the request (${gatewayAuthError}). The proxy key may not be approved for service 15129466.`,
};
}
let payload;
try {
payload = JSON.parse(text);
} catch {
return { error: "upstream_invalid_response", message: "Procurement upstream did not return valid JSON." };
}
if (isAuthResultCode(payload?.response?.header?.resultCode)) {
return {
error: "upstream_forbidden",
message: `Procurement upstream rejected the request (${payload.response.header.resultMsg || "auth error"}). The proxy key may not be approved for service 15129466.`,
};
}
let extracted;
try {
extracted = extractSanctionItems(payload);
} catch (err) {
return { error: "upstream_error", message: `Procurement upstream error response: ${err.message}` };
}
return {
bizno,
total_count: extracted.totalCount,
active_sanctions: extracted.items,
match_basis:
"Exact business-number match (inqryDiv=1) — the list of sanctions in force at query time (first 100). Expired/lifted sanctions and non-registered suppliers are not provided by the upstream.",
};
}
module.exports = {
G2B_SANCTION_URL,
normalizeG2bSanctionQuery,
extractSanctionItems,
fetchG2bSanctions,
};

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

@ -0,0 +1,199 @@
// National Pension Service (NPS) workplace-coverage API wrapper.
// Proxies data.go.kr 3046071 (NpsBplcInfoInqireServiceV2) XML endpoints and
// keeps the operator's DATA_GO_KR_API_KEY server-side.
//
// The upstream returns business registration numbers masked to the first 6
// digits, so identity is established by (workplace name + 6-digit prefix) only.
// When more than one candidate matches we return the candidate list as-is and
// do not assert which one is the queried business.
const NPS_BASE_URL = "https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2";
// data.go.kr gateway-level auth/quota reason codes (OpenAPI_ServiceResponse).
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function digitsOnly(value) {
return String(value ?? "").replace(/[^0-9]/g, "");
}
// Accepts wkplNm (workplace/business name) and an optional business
// registration number whose first 6 digits are used as a prefix filter.
function normalizeNationalPensionQuery(query = {}) {
const wkplNm = trimOrNull(query.wkplNm ?? query.name ?? query.b_nm);
if (!wkplNm) {
throw new Error(
"Provide wkplNm (workplace/business name). The NPS API only discloses the first 6 digits of the business number, so a name is required."
);
}
const rawBno = trimOrNull(query.b_no ?? query.bno ?? query.bzowrRgstNo);
const bnoDigits = rawBno ? digitsOnly(rawBno) : "";
if (rawBno && !/^\d{10}$/.test(bnoDigits)) {
throw new Error("Provide b_no as a 10-digit business registration number.");
}
const bnoPrefix = bnoDigits ? bnoDigits.slice(0, 6) : "";
return { wkplNm, bnoPrefix };
}
// Regex-based parser for the flat <item> structure data.go.kr returns.
// Not a general-purpose XML parser — sufficient for NPS responses.
function parseNationalPensionXml(xmlText) {
const text = String(xmlText ?? "");
if (text.includes("<OpenAPI_ServiceResponse")) {
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "";
const kind = AUTH_REASON_CODES.has(reasonCode) ? "auth-error" : "error";
return { kind, reason: `${authMsg || "SERVICE ERROR"} (code ${reasonCode})`.trim() };
}
const resultCode = (text.match(/<resultCode>([^<]*)<\/resultCode>/) || [])[1]?.trim() || "";
const resultMsg = (text.match(/<resultMsg>([^<]*)<\/resultMsg>/) || [])[1]?.trim() || "";
if (resultCode && !["00", "0"].includes(resultCode)) {
return { kind: "error", reason: `resultCode=${resultCode} ${resultMsg}`.trim() };
}
const items = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let itemMatch;
while ((itemMatch = itemRegex.exec(text)) !== null) {
const obj = {};
const fieldRegex = /<(\w+)>([\s\S]*?)<\/\1>/g;
let fieldMatch;
while ((fieldMatch = fieldRegex.exec(itemMatch[1])) !== null) {
obj[fieldMatch[1]] = fieldMatch[2].trim();
}
items.push(obj);
}
const totalCount = (text.match(/<totalCount>([^<]*)<\/totalCount>/) || [])[1]?.trim() || "";
return { kind: "items", items, totalCount };
}
async function callOperation(operation, params, serviceKey, fetchImpl) {
const url = new URL(`${NPS_BASE_URL}/${operation}`);
url.searchParams.set("serviceKey", serviceKey);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
const doFetch = fetchImpl || global.fetch;
let response;
try {
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
} catch (err) {
return { kind: "error", reason: `Upstream request failed: ${err.message}` };
}
if (response.status === 401 || response.status === 403) {
return { kind: "auth-error", reason: `HTTP ${response.status}` };
}
if (!response.ok) {
const body = (await response.text()).slice(0, 80).trim();
return { kind: "error", reason: `upstream HTTP ${response.status} ${body}`.trim() };
}
return parseNationalPensionXml(await response.text());
}
// Orchestrates the three NPS operations: basic search → dedup → (when a single
// candidate is identified) detail + monthly-status. Mirrors the reference Python
// provider so the proxy returns a clean, structured result with the key never
// leaving the server.
async function fetchNationalPensionWorkplace({ wkplNm, bnoPrefix = "", serviceKey, fetchImpl = global.fetch }) {
const basic = await callOperation(
"getBassInfoSearchV2",
{ wkplNm, bzowrRgstNo: bnoPrefix, pageNo: 1, numOfRows: 100 },
serviceKey,
fetchImpl
);
if (basic.kind === "auth-error") {
return { error: "upstream_forbidden", message: `NPS upstream rejected the request (${basic.reason}). The proxy key may not be approved for service 3046071.` };
}
if (basic.kind === "error") {
return { error: "upstream_error", message: basic.reason };
}
// Defensive re-filter by the 6-digit prefix (trust upstream but verify).
let candidates = basic.items;
if (bnoPrefix) {
candidates = candidates.filter((it) => digitsOnly(it.bzowrRgstNo).startsWith(bnoPrefix) || !it.bzowrRgstNo);
}
// The same workplace repeats per dataCrtYm; keep the latest month per
// (wkplNm + road address).
const grouped = new Map();
for (const it of candidates) {
const key = `${(it.wkplNm || "").trim()}\u001f${(it.wkplRoadNmDtlAddr || "").trim()}`;
const prev = grouped.get(key);
if (!prev || (it.dataCrtYm || "") > (prev.dataCrtYm || "")) {
grouped.set(key, it);
}
}
const deduped = [...grouped.values()].sort((a, b) => (b.dataCrtYm || "").localeCompare(a.dataCrtYm || ""));
const exact = deduped.filter((it) => (it.wkplNm || "").trim() === wkplNm.trim());
const chosen = deduped.length === 1 ? deduped[0] : (exact.length === 1 ? exact[0] : null);
let detail = null;
let monthly = null;
if (chosen && chosen.seq) {
const detailResult = await callOperation(
"getDetailInfoSearchV2",
{ seq: chosen.seq, dataCrtYm: chosen.dataCrtYm || "" },
serviceKey,
fetchImpl
);
if (detailResult.kind === "items") {
detail = detailResult.items.length ? detailResult.items : null;
} else if (detailResult.kind === "auth-error") {
return { error: "upstream_forbidden", message: `NPS detail lookup rejected the request (${detailResult.reason}). The proxy key may not be approved for service 3046071.` };
} else {
return { error: "upstream_error", message: `NPS detail lookup failed (${detailResult.reason}).` };
}
const periodResult = await callOperation(
"getPdAcctoSttusInfoSearchV2",
{ seq: chosen.seq },
serviceKey,
fetchImpl
);
if (periodResult.kind === "items") {
monthly = periodResult.items.length
? [...periodResult.items].sort((a, b) => (a.dataCrtYm || "").localeCompare(b.dataCrtYm || ""))
: null;
} else if (periodResult.kind === "auth-error") {
return { error: "upstream_forbidden", message: `NPS monthly status lookup rejected the request (${periodResult.reason}). The proxy key may not be approved for service 3046071.` };
} else {
return { error: "upstream_error", message: `NPS monthly status lookup failed (${periodResult.reason}).` };
}
}
return {
query: { wkplNm, bzowrRgstNo_prefix: bnoPrefix || null },
candidate_count: deduped.length,
candidates: deduped,
raw_row_count: candidates.length,
selected_candidate: chosen,
detail,
monthly_status: monthly,
disclosure_note:
"The business number is disclosed only to its first 6 digits (the rest is masked), so an exact-number match is impossible. Candidates matching name + 6-digit prefix are listed; when several match, identification is left to the caller."
};
}
module.exports = {
NPS_BASE_URL,
normalizeNationalPensionQuery,
parseNationalPensionXml,
fetchNationalPensionWorkplace,
};

View file

@ -42,6 +42,14 @@ const {
const { fetchNearbyParkingLots } = require("./parking-lots");
const { searchRegionCode } = require("./region-lookup");
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
const { normalizeNationalPensionQuery, fetchNationalPensionWorkplace } = require("./national-pension");
const { normalizeFscCorpQuery, fetchFscCorpOutline } = require("./fsc-corp");
const { normalizeG2bSanctionQuery, fetchG2bSanctions } = require("./g2b-sanction");
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 +192,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 +1899,11 @@ 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),
nationalPensionConfigured: Boolean(config.molitApiKey),
fscCorpConfigured: Boolean(config.molitApiKey),
g2bSanctionConfigured: Boolean(config.molitApiKey),
koreanLawConfigured: Boolean(config.lawOc)
},
auth: {
tokenRequired: false
@ -3366,6 +3381,94 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply
}));
// Shared handler for keyed data.go.kr GET lookups that reuse the operator's
// DATA_GO_KR_API_KEY server-side (national pension, FSC corp, G2B sanctions).
async function handleKeyedDataGoKrLookup({ route, normalizer, fetcher, request, reply }) {
let normalized;
try {
normalized = normalizer(request.query || {});
} catch (error) {
reply.code(400);
return { error: "bad_request", message: error.message };
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const cacheKey = makeCacheKey({ route, ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: { ...cached.proxy, cache: { hit: true, ttl_ms: config.cacheTtlMs } }
};
}
let result;
try {
result = await fetcher({ ...normalized, serviceKey: config.molitApiKey });
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: error.message,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const keyedErrorStatus = {
upstream_forbidden: 502,
upstream_timeout: 504,
upstream_invalid_response: 502,
upstream_error: 502
};
if (result && result.error) {
reply.code(keyedErrorStatus[result.error] || 502);
return {
...result,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
};
}
const payload = {
...result,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
}
app.get("/v1/national-pension/workplace", async (request, reply) => handleKeyedDataGoKrLookup({
route: "national-pension-workplace",
normalizer: normalizeNationalPensionQuery,
fetcher: fetchNationalPensionWorkplace,
request,
reply
}));
app.get("/v1/fsc/corp-outline", async (request, reply) => handleKeyedDataGoKrLookup({
route: "fsc-corp-outline",
normalizer: normalizeFscCorpQuery,
fetcher: fetchFscCorpOutline,
request,
reply
}));
app.get("/v1/g2b/sanctioned-supplier", async (request, reply) => handleKeyedDataGoKrLookup({
route: "g2b-sanctioned-supplier",
normalizer: normalizeG2bSanctionQuery,
fetcher: fetchG2bSanctions,
request,
reply
}));
async function handleKstartupRoute({ operation, route, request, reply }) {
let normalized;
try {
@ -3515,6 +3618,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,405 @@ 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");
});
// ---------------------------------------------------------------------------
// Business due-diligence keyed routes: national pension, FSC corp, G2B sanction
// ---------------------------------------------------------------------------
const {
normalizeNationalPensionQuery,
parseNationalPensionXml
} = require("../src/national-pension");
const { normalizeFscCorpQuery } = require("../src/fsc-corp");
const { normalizeG2bSanctionQuery, extractSanctionItems } = require("../src/g2b-sanction");
function npsItemsXml(items) {
const body = items
.map(
(it) =>
"<item>" +
Object.entries(it)
.map(([k, v]) => `<${k}>${v}</${k}>`)
.join("") +
"</item>"
)
.join("");
return (
'<?xml version="1.0" encoding="UTF-8"?><response><header><resultCode>00</resultCode>' +
`<resultMsg>NORMAL SERVICE.</resultMsg></header><body><items>${body}</items>` +
`<totalCount>${items.length}</totalCount></body></response>`
);
}
test("national-pension normalizer requires a workplace name and derives the 6-digit prefix", () => {
assert.throws(() => normalizeNationalPensionQuery({}), /wkplNm/);
assert.deepEqual(normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123-45-67890" }), {
wkplNm: "테스트상사",
bnoPrefix: "123456"
});
assert.throws(() => normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123" }), /10-digit/);
});
test("parseNationalPensionXml classifies gateway auth errors and item lists", () => {
const auth = parseNationalPensionXml(
"<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>"
);
assert.equal(auth.kind, "auth-error");
const ok = parseNationalPensionXml(npsItemsXml([{ wkplNm: "갑", seq: "1" }]));
assert.equal(ok.kind, "items");
assert.equal(ok.items[0].wkplNm, "갑");
});
test("national-pension route orchestrates basic+detail+monthly and keeps the key server-side", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
const u = String(url);
calls.push(u);
if (u.includes("getBassInfoSearchV2")) {
return new Response(
npsItemsXml([
{
wkplNm: "테스트상사",
bzowrRgstNo: "123456****",
seq: "777",
dataCrtYm: "202605",
wkplRoadNmDtlAddr: "서울"
},
{
wkplNm: "테스트상사",
bzowrRgstNo: "123456****",
seq: "777",
dataCrtYm: "202604",
wkplRoadNmDtlAddr: "서울"
}
]),
{ status: 200, headers: { "content-type": "application/xml" } }
);
}
if (u.includes("getDetailInfoSearchV2")) {
return new Response(npsItemsXml([{ jnngpCnt: "120", crrmmNtcAmt: "5000000" }]), {
status: 200,
headers: { "content-type": "application/xml" }
});
}
if (u.includes("getPdAcctoSttusInfoSearchV2")) {
return new Response(npsItemsXml([{ dataCrtYm: "202604" }, { dataCrtYm: "202605" }]), {
status: 200,
headers: { "content-type": "application/xml" }
});
}
throw new Error(`unexpected NPS URL: ${u}`);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const res = await app.inject({
method: "GET",
url: "/v1/national-pension/workplace?name=" + encodeURIComponent("테스트상사") + "&b_no=1234567890"
});
const body = res.json();
assert.equal(res.statusCode, 200);
assert.equal(body.candidate_count, 1, "month-duplicated rows collapse to one workplace");
assert.equal(body.raw_row_count, 2);
assert.equal(body.selected_candidate.seq, "777");
assert.equal(body.detail[0].jnngpCnt, "120");
assert.equal(body.monthly_status[0].dataCrtYm, "202604");
assert.equal(body.proxy.cache.hit, false);
assert.deepEqual(calls.map((u) => new URL(u).pathname.split("/").pop()), [
"getBassInfoSearchV2",
"getDetailInfoSearchV2",
"getPdAcctoSttusInfoSearchV2"
]);
assert.ok(calls.every((u) => new URL(u).searchParams.get("serviceKey") === "data-go-key"));
assert.equal(JSON.stringify(body).includes("data-go-key"), false, "service key must not leak into the response");
const cached = await app.inject({
method: "GET",
url: "/v1/national-pension/workplace?name=" + encodeURIComponent("테스트상사") + "&b_no=1234567890"
});
assert.equal(cached.json().proxy.cache.hit, true);
});
test("national-pension route reports missing key and rejects nameless queries", async (t) => {
const app = buildServer();
t.after(async () => {
await app.close();
});
const noKey = await app.inject({ method: "GET", url: "/v1/national-pension/workplace?name=갑" });
assert.equal(noKey.statusCode, 503);
assert.equal(noKey.json().error, "upstream_not_configured");
const keyedApp = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
await keyedApp.close();
});
const bad = await keyedApp.inject({ method: "GET", url: "/v1/national-pension/workplace" });
assert.equal(bad.statusCode, 400);
assert.equal(bad.json().error, "bad_request");
});
test("fsc corp normalizer requires a corporate name", () => {
assert.throws(() => normalizeFscCorpQuery({}), /corpNm/);
assert.deepEqual(normalizeFscCorpQuery({ name: "테스트", b_no: "123-45-67890" }), {
corpNm: "테스트",
bno: "1234567890"
});
assert.throws(() => normalizeFscCorpQuery({ name: "테스트", b_no: "123" }), /10-digit/);
});
test("fsc corp-outline route returns name-matched candidates and cross-checks bzno when present", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async (url) => {
fetchCalls += 1;
assert.match(String(url), /corpNm=/);
assert.match(String(url), /serviceKey=data-go-key/);
return new Response(
JSON.stringify({
response: {
header: { resultCode: "00", resultMsg: "NORMAL SERVICE." },
body: { items: { item: [{ corpNm: "테스트", crno: "1101111111111", bzno: "1234567890" }] } }
}
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const res = await app.inject({
method: "GET",
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트") + "&b_no=1234567890"
});
const body = res.json();
assert.equal(res.statusCode, 200);
assert.equal(body.candidate_count, 1);
assert.equal(body.b_no_cross_check.checked, true);
assert.equal(body.b_no_cross_check.matched_candidates.length, 1);
const cached = await app.inject({
method: "GET",
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트") + "&b_no=1234567890"
});
assert.equal(cached.json().proxy.cache.hit, true);
assert.equal(fetchCalls, 1);
});
test("fsc corp-outline route maps upstream 403 to a 502 forbidden error", async (t) => {
const originalFetch = global.fetch;
global.fetch = async () => new Response("Forbidden", { status: 403 });
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const res = await app.inject({
method: "GET",
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트")
});
assert.equal(res.statusCode, 502);
assert.equal(res.json().error, "upstream_forbidden");
});
test("g2b sanction normalizer enforces a 10-digit business number", () => {
assert.throws(() => normalizeG2bSanctionQuery({ bizno: "123" }), /10-digit/);
assert.deepEqual(normalizeG2bSanctionQuery({ b_no: "123-45-67890" }), { bizno: "1234567890" });
});
test("g2b extractSanctionItems tolerates dict and single-item variants", () => {
assert.deepEqual(
extractSanctionItems({ response: { header: { resultCode: "00" }, body: { items: "", totalCount: 0 } } }).items,
[]
);
const single = extractSanctionItems({
response: { header: { resultCode: "00" }, body: { items: { item: { bizNm: "갑" } }, totalCount: 1 } }
});
assert.equal(single.items.length, 1);
});
test("g2b sanctioned-supplier route returns active sanctions and uses capital-S ServiceKey", async (t) => {
const originalFetch = global.fetch;
const seenUrls = [];
global.fetch = async (url) => {
seenUrls.push(String(url));
return new Response(
JSON.stringify({
response: {
header: { resultCode: "00" },
body: { items: { item: [{ bizno: "1234567890", bizNm: "갑", rstrtSttDt: "20250101" }] }, totalCount: 1 }
}
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const res = await app.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
const body = res.json();
assert.equal(res.statusCode, 200);
assert.equal(body.total_count, 1);
assert.equal(body.active_sanctions[0].bizNm, "갑");
assert.match(seenUrls[0], /ServiceKey=data-go-key/);
assert.match(seenUrls[0], /inqryDiv=1/);
const cached = await app.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
assert.equal(cached.json().proxy.cache.hit, true);
assert.equal(seenUrls.length, 1);
const noKey = buildServer();
t.after(async () => {
await noKey.close();
});
const missing = await noKey.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
assert.equal(missing.statusCode, 503);
});
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,5 +1,11 @@
# toss-securities
## 0.5.0
### Minor Changes
- 66f12cb: 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.
## 0.4.0
### Minor Changes

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",
"version": "0.5.0",
"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

@ -0,0 +1,131 @@
---
name: saramin-talent-search
description: 사람인 기업회원 인재풀 로그인 세션에서 마스킹된 후보 정보를 검색·비교해 유료 열람 전 shortlist를 만듭니다.
license: MIT
metadata:
category: recruiting
locale: ko-KR
phase: v1
---
# saramin-talent-search
사람인 인재풀에서 유료 열람/연락처 확인/제안 발송 전에 현재 보이는 마스킹 후보 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
## Use when
- 사용자가 사람인 인재풀에서 후보를 찾아달라고 요청한다.
- 기업회원 로그인/2차 인증이 완료된 브라우저 세션에서 후보를 검색해야 한다.
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
## Hard boundaries
Allowed:
- 사람인 인재풀 브라우저 세션 열기 및 검색 필터 입력
- 현재 보이는 마스킹 후보 목록/프로필/이력서 읽기
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
Never do without explicit user handoff/confirmation:
- 유료 이력서 열람, 연락처 확인, 마스킹 해제
- 포지션/입사 제안 발송
- 스크랩, 관심후보 등록, 메모, 후보 상태 변경
- 결제/유료 상품 사용
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
- 후보 개인정보 장기 저장 또는 대량 수집
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
## Primary access
Open:
```text
https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
```
If login or first-device verification is required, pause and show:
```text
사람인 인재풀 검색은 기업회원 로그인과, 처음 사용하는 브라우저/기기에서는 2차 인증이 필요할 수 있습니다.
제가 브라우저로 사람인 인재풀 검색 페이지를 열어둘게요.
열린 브라우저에서 직접 로그인과 필요한 경우 2차 인증을 완료해 주세요.
비밀번호, 인증번호, 세션 쿠키는 저에게 알려주지 마세요.
인재풀 검색 화면이 보이면 “인증 완료했어”라고 알려주세요.
그 다음 같은 브라우저 세션에서 검색을 이어가겠습니다.
```
Resume only in the same browser session after the user confirms the search UI is visible.
## Input normalization
Extract or infer:
- role_title
- must_have / nice_to_have
- negative_keywords
- career min/max
- location/work_area
- role-specific evaluation signals
- limit / requested Top N
Do not block on missing details when a reasonable first search is possible.
## Workflow
1. Open the primary URL and verify corporate login plus search UI visibility.
2. Ask the user to log in/verify manually only when required; never handle credentials.
3. Apply filters: keyword, 직무/직종, 경력, 지역, recent update/activity/relevance sorting, exclusions when supported.
4. Build a candidate pool from visible rows.
5. Before final Top N, open normal profile/resume detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary/job-seeking state, portfolio links if visible, recent activity.
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
8. Return Korean shortlist with direct URL per recommended candidate.
Do not finalize Top N from list rows only unless details are inaccessible or paid-walled. If so, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
## Permission guidance
Safe after normal tool/browser approval: opening the search page, typing filters, pressing search/apply, scrolling results, opening normal candidate detail links, reading currently visible masked/free text.
Must stop/handoff: paid unlock, contact reveal, proposal/send, scrap/interest, memo/status changes, payment, credential/OTP/cookie handling.
## URL extraction guidance
Every recommended candidate needs a direct Saramin profile/resume URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
## Output shape
```text
사람인 인재풀 shortlist
검색 조건
- 포지션: ...
- 필수/우대/제외 조건: ...
- 경력/지역: ...
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
유료 열람 추천 Top N
1. 후보 A
- 점수: 88/100
- 근거: ...
- 보이는 경력/성과: ...
- 리스크: ...
- 추천 액션: 채용 담당자가 유료 열람 검토
- URL: ...
보류 후보
- ...
검색 한계
- 마스킹/현재 표시 정보만 분석했음
- 연락처/실명/비공개 정보는 열람하지 않음
- 유료 액션은 실행하지 않음
```
## Failure modes
- Login/2FA required: open the page and let the user complete it manually.
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to no-login scraping.
- Paid wall/contact wall: stop and mark as manual paid review needed.
- Empty results: adjust keywords, career, region, update/relevance filters.
- UI changed: rediscover the visible form/data flow before updating instructions.

View file

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

View file

@ -311,8 +311,8 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
assert.match(readme, /\| 카카오톡 Mac 아카이브 검색 \|/);
assert.match(readme, /\[카카오톡 Mac 아카이브 검색\]\(docs\/features\/kakaotalk-mac\.md\)/);
assert.match(install, /--skill kakaotalk-mac/);
});
@ -634,34 +634,42 @@ test("hosted proxy docs keep self-host overrides inactive and demonstrate resolv
}
});
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
test("kakaotalk-mac skill documents katok archive search usage", () => {
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
assert.match(skill, /^name: kakaotalk-mac$/m);
assert.match(skill, /kakaocli/);
assert.match(skill, /macOS/i);
assert.match(skill, /KakaoTalk/i);
assert.match(skill, /Full Disk Access/i);
assert.match(skill, /Accessibility/i);
assert.match(skill, /--me/);
assert.match(skill, /confirm before sending/i);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
assert.match(doc, /cache|캐시/);
assert.match(doc, /read-only|읽기 전용/i);
assert.doesNotMatch(doc, /`query`/);
assert.match(doc, /\bkatok\b/);
assert.match(doc, /macOS/i);
assert.match(doc, /KakaoTalk/i);
assert.match(doc, /Full Disk Access/i);
assert.match(doc, /katok doctor --json/);
assert.match(doc, /katok permissions macos/);
assert.match(doc, /katok sync --source macos --json/);
assert.match(doc, /katok index --json/);
assert.match(doc, /katok search keyword/);
assert.match(doc, /katok search bm25/);
assert.match(doc, /katok search semantic/);
assert.match(doc, /katok chunk get/);
assert.match(doc, /katok chunk context/);
assert.match(doc, /katok chunk parent/);
assert.match(doc, /(no|never|do not|don't|not).{0,80}((direct|raw).{0,40}(DB|database).{0,40}read|directly read.{0,40}(DB|database))/i);
assert.match(doc, /(no|never|do not|don't|not).{0,80}(auth|authentication).{0,40}caches?/i);
assert.match(doc, /(no|never|do not|don't|not).{0,80}decryption material/i);
assert.doesNotMatch(doc, /kakaocli/);
assert.doesNotMatch(doc, /python3 scripts\/kakaotalk_mac\.py/);
assert.doesNotMatch(doc, /send --me/);
assert.doesNotMatch(doc, /delete-last/);
assert.doesNotMatch(doc, /confirm before sending/i);
assert.doesNotMatch(doc, /SQLCipher key/i);
}
});
@ -1851,7 +1859,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");
@ -1862,6 +1870,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/);
@ -1900,9 +1914,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");
@ -1910,6 +1925,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", () => {
@ -1922,9 +1951,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/);
@ -1982,7 +2016,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"));
@ -1998,26 +2032,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"));
@ -2034,30 +2072,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,
@ -3968,7 +4000,7 @@ test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver chang
const README_SKILL_NAME_COLUMN_MAPPING = [
["SRT 예매", "srt-booking"],
["KTX 예매", "ktx-booking"],
["카카오톡 Mac CLI", "kakaotalk-mac"],
["카카오톡 Mac 아카이브 검색", "kakaotalk-mac"],
["서울 지하철 도착정보 조회", "seoul-subway-arrival"],
["지하철 분실물 조회", "subway-lost-property"],
["긱뉴스 조회", "geeknews-search"],

View file

@ -1,473 +0,0 @@
from __future__ import annotations
import hashlib
import json
import io
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import scripts.kakaotalk_mac as kakaotalk_mac
def sha512_hex(value: int) -> str:
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
def make_resolved_auth(
*,
user_id: int = 123,
uuid: str = "uuid",
database_path: Path | None = None,
database_name: str = "db-name",
key: str = "super-secret",
source: str = "cache",
) -> kakaotalk_mac.ResolvedAuth:
return kakaotalk_mac.ResolvedAuth(
user_id=user_id,
uuid=uuid,
database_path=database_path or Path("/tmp/kakaotalk.db"),
database_name=database_name,
key=key,
source=source,
)
class KakaoTalkMacHelperTests(unittest.TestCase):
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
active_hash = sha512_hex(123456)
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>AlertKakaoIDsList</key>
<array>
<integer>111</integer>
<integer>222</integer>
</array>
<key>userId</key>
<integer>333</integer>
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
<integer>5</integer>
</dict>
</plist>
"""
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
def test_discover_database_files_filters_hex_names(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
root = Path(tempdir)
expected = [
root / ("a" * 78),
root / ("b" * 78 + ".db"),
]
for path in expected:
path.write_text("", encoding="utf-8")
(root / ("c" * 40)).write_text("", encoding="utf-8")
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
discovered = kakaotalk_mac.discover_database_files(root)
self.assertEqual(discovered, expected)
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
target_user_id = 123456
recovered = kakaotalk_mac.recover_user_id_from_sha512(
sha512_hex(target_user_id),
max_user_id=200000,
workers=1,
chunk_size=5000,
)
self.assertEqual(recovered, target_user_id)
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
target_user_id = 654321
active_hash = sha512_hex(target_user_id)
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
verification_calls: list[int] = []
state = kakaotalk_mac.DetectionState(
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
candidate_user_ids=[111, 222],
active_account_hash=active_hash,
database_files=[database_path],
)
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
verification_calls.append(candidate.user_id)
return candidate.user_id == target_user_id
resolved = kakaotalk_mac.resolve_auth_state(
state,
verify_access=verify,
cache_path=cache_path,
max_user_id=700000,
workers=1,
chunk_size=10000,
)
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
self.assertEqual(verification_calls, [111, 222, target_user_id])
self.assertEqual(resolved.user_id, target_user_id)
self.assertEqual(resolved.database_path, database_path)
self.assertEqual(cache_payload["user_id"], target_user_id)
self.assertEqual(cache_payload["database_path"], str(database_path))
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
cache_path.write_text("{bad json\n", encoding="utf-8")
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
cache_path.write_text("{bad json\n", encoding="utf-8")
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
):
cached = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=None,
uuid_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(cached, resolved)
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once()
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=999,
uuid_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=999,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=None,
uuid_override="override-uuid",
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with("override-uuid")
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_render_auth_text_redacts_key_material(self) -> None:
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
self.assertNotIn("super-secret-key", rendered)
self.assertNotIn("--key", rendered)
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
auth = make_resolved_auth()
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
def test_build_parser_exposes_safe_helper_commands_without_raw_query(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertEqual(sorted(subcommands), ["auth", "chats", "delete", "delete-last", "messages", "schema", "search"])
self.assertNotIn("query", subcommands)
def test_build_parser_exposes_delete_commands_with_safe_dry_run(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertIn("delete", subcommands)
self.assertIn("delete-last", subcommands)
parsed = parser.parse_args(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(parsed.command, "delete")
self.assertEqual(parsed.chat, "팀 공지방")
self.assertEqual(parsed.message_id, 42)
self.assertTrue(parsed.everyone)
self.assertTrue(parsed.dry_run)
def test_select_delete_target_by_message_id_requires_matching_outbound_message(self) -> None:
messages = [
{"id": 41, "text": "older", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "sent follow-up", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "sent follow-up")
self.assertTrue(target.is_from_me)
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.select_delete_target(messages, message_id=404, delete_last=False, everyone=False)
def test_select_delete_target_rejects_non_outbound_message_before_delete_for_me(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("sent by this KakaoTalk account", str(context.exception))
def test_select_delete_last_uses_most_recent_message_from_me(self) -> None:
messages = [
{"id": 100, "text": "latest inbound", "is_from_me": False, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 99, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
{"id": 98, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=True)
self.assertEqual(target.message_id, 99)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_sorts_unordered_messages_by_timestamp_then_id(self) -> None:
messages = [
{"id": 40, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 41, "text": "middle outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_uses_id_as_tiebreaker_for_equal_timestamps(self) -> None:
messages = [
{"id": 40, "text": "same time older id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 43, "text": "same time newer id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 43)
self.assertEqual(target.text, "same time newer id")
def test_select_delete_target_rejects_everyone_for_non_outbound_message(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("--everyone", str(context.exception))
def test_build_delete_osascript_mentions_chat_text_and_delete_scope(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("팀 공지방", script)
self.assertIn("테스트 메시지", script)
self.assertIn("모두에게서 삭제", script)
self.assertIn("Delete for Everyone", script)
self.assertIn("matchingElements", script)
self.assertIn("Could not choose the requested delete scope", script)
def test_build_delete_osascript_uses_fail_closed_exact_transcript_resolver(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertNotIn("entire contents of front window", script)
self.assertNotIn("contains messageText", script)
self.assertNotIn("contains chatName", script)
self.assertIn("set normalizedMessageText to normalizeText(messageText)", script)
self.assertIn("set normalizedChatName to normalizeText(chatName)", script)
self.assertIn("if normalizeText(candidateValue) is normalizedMessageText then", script)
self.assertIn("if normalizeText(chatCandidateValue) is normalizedChatName then", script)
self.assertIn("set messageListCandidates to", script)
self.assertIn("AXShowMenu", script)
self.assertIn("Target message text matched multiple visible targetable message bubbles", script)
self.assertIn("Could not verify the active KakaoTalk chat", script)
self.assertNotIn("set messageTimestamp to", script)
def test_run_delete_dry_run_validates_target_but_skips_ui_side_effect(self) -> None:
stdout = io.StringIO()
auth = make_resolved_auth()
messages = [{"id": 42, "text": "검증된 메시지", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"}]
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=auth) as resolve_auth,
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=messages) as load_messages,
mock.patch.object(kakaotalk_mac, "run_delete_automation") as run_delete,
mock.patch("sys.stdout", stdout),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(exit_code, 0)
resolve_auth.assert_called_once()
load_messages.assert_called_once_with("팀 공지방", auth, limit=200)
run_delete.assert_not_called()
self.assertIn("DRY RUN", stdout.getvalue())
self.assertIn("message_id=42", stdout.getvalue())
self.assertIn("검증된 메시지", stdout.getvalue())
def test_run_delete_dry_run_fails_when_message_id_is_missing(self) -> None:
stderr = io.StringIO()
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=make_resolved_auth()),
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=[]),
mock.patch("sys.stderr", stderr),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "404", "--dry-run"])
self.assertEqual(exit_code, 1)
self.assertIn("Message id 404", stderr.getvalue())
def test_select_delete_target_rejects_duplicate_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same", "is_from_me": True},
{"id": 41, "text": "same", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_duplicate_normalized_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same visible text", "is_from_me": True},
{"id": 41, "text": "same visible text", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_empty_or_non_text_delete_target(self) -> None:
messages = [{"id": 42, "text": " ", "type": "photo", "is_from_me": True}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("non-empty text", str(context.exception))
def test_build_delete_osascript_fails_when_final_confirmation_is_missing(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("set didConfirmDelete to false", script)
self.assertIn("set didConfirmDelete to true", script)
self.assertIn("if didConfirmDelete is false then error", script)
self.assertIn("Could not confirm the KakaoTalk delete dialog", script)
def test_build_parser_rejects_negative_max_user_id(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--max-user-id", "-1"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be non-negative", stderr.getvalue())
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--chunk-size", "0"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be positive", stderr.getvalue())
if __name__ == "__main__":
unittest.main()

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` 전이면 계좌/포트폴리오 조회가 실패할 수 있다.
- 계좌/주문 정보는 민감하므로 출력 범위를 과도하게 넓히지 않는다.