Compare commits

..

90 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
e25f8dd9ab 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>
2026-06-10 14:32:44 +09:00
happy-nut
a8a71eed11 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>
2026-06-10 14:22:54 +09:00
iamiks
52dbfee064
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>
2026-06-09 10:57:54 +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
Jeffrey (Dongkyu) Kim
acc66861ea Merge remote-tracking branch 'origin/main' into dev
# Conflicts:
#	.changeset/issue-268-naver-map-route.md
2026-06-05 22:49:47 +09:00
Jeffrey (Dongkyu) Kim
bbba283151 Archive unsupported map skills 2026-06-05 22:24:15 +09:00
Jeffrey (Dongkyu) Kim
46f44ed724 chore: remove startup-support skill 2026-06-05 17:21:58 +09:00
Jeffrey (Dongkyu) Kim
cc64d66d56 test: isolate python test dependencies 2026-06-05 17:03:27 +09:00
Jeffrey (Dongkyu) Kim
7c1a4530cf test: remove runtime pip install from root ci 2026-06-05 17:00:02 +09:00
Jeffrey (Dongkyu) Kim
346ce7f516 fix(startup-support): use K-Startup proxy surface 2026-06-05 13:03:28 +09:00
Jeffrey (Dongkyu) Kim
e336f7898c chore: resolve integration diagnostics 2026-05-31 17:28:59 +09:00
Jeffrey (Dongkyu) Kim
352876f915 chore(plugin): include startup-support in manifest 2026-05-31 17:26:08 +09:00
Jeffrey (Dongkyu) Kim
581b0344ce Merge commit 'c27a3e9' into ulw-merge-seven-prs
# Conflicts:
#	package.json
2026-05-31 17:25:22 +09:00
Jeffrey (Dongkyu) Kim
cc768f4031 Merge commit 'cff6b29' into ulw-merge-seven-prs 2026-05-31 17:24:25 +09:00
Jeffrey (Dongkyu) Kim
51388a539e Merge commit 'b6b0c70' into ulw-merge-seven-prs
# Conflicts:
#	scripts/validate-skills.sh
2026-05-31 17:24:25 +09:00
Jeffrey (Dongkyu) Kim
fe08b8e068 Merge remote-tracking branch 'origin/pr/293' into ulw-merge-seven-prs 2026-05-31 17:24:05 +09:00
Jeffrey (Dongkyu) Kim
e76c125014 Merge remote-tracking branch 'origin/pr/295' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
a490fcb0c0 Merge remote-tracking branch 'origin/pr/296' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
9dc3577742 Merge remote-tracking branch 'origin/pr/298' into ulw-merge-seven-prs 2026-05-31 17:24:04 +09:00
Jeffrey (Dongkyu) Kim
c27a3e9151 fix(foresttrip-vacancy): preserve category-specific vacancy rows 2026-05-31 17:23:57 +09:00
Jeffrey (Dongkyu) Kim
cff6b29ff9 fix(startup-support): remove fabricated detail data 2026-05-31 17:23:57 +09:00
Jeffrey (Dongkyu) Kim
b6b0c70091 fix(plugin): satisfy Claude marketplace manifest schema 2026-05-31 17:23:57 +09:00
Donghun Seol
edb2892dda test(foresttrip-vacancy): harden availability output coverage 2026-05-29 21:05:31 +09:00
Donghun Seol
b2404e99be test(foresttrip-vacancy): add print_text regression coverage
라이브 불변식 검증(예비 제외·dedup·useDt 범위)이 통과한 뒤,
사용자 대면 출력 함수인 print_text가 테스트 0개였던 갭을 메운다.

- PrintTextTest 2개: 결과 렌더링 / 빈 결과 메시지
- stub_fetch는 fetch_one의 미사용 인자를 **_ 로 흡수해 lint 경고 제거

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:57:25 +09:00
Donghun Seol
00a6d9feae fix(foresttrip-vacancy): exclude 예비 rooms, gate useDt strictly, dedup duplicate rooms
월별예약조회 API가 srchDate 단일 일자 요청에도 5일 윈도우를 반환하고,
"예비"로 표기된 운영자 보유분이 raw 응답에 포함되며, 같은 객실이 다른
goodsId로 중복 표시되는 세 가지 문제를 한꺼번에 fix한다.

수정:
- collect_results 안에 strict useDt gate 추가 (today~last_day 범위 밖 행 차단)
- is_reserve_room() helper로 goodsNm에 "예비" 포함 객실 제외
- (forest_id, use_dt, name) 단위 dedup으로 중복 행 제거
- is_available()는 시그니처/로직 변경 없이 booking-state predicate 유지

추가:
- foresttrip-vacancy/tests/ 18개 단위 테스트 (mock + fixture 기반)
- IsReserveRoomTest, IsAvailableTest, CollectResultsFilterTest,
  StrictUseDtGateTest, GroundTruthTest 다섯 클래스
- 거제·구재봉 fixture로 사용자 라이브 검증 결과 회귀 보호
- package.json lint·test 스크립트에 등록

문서:
- SKILL.md: API 5일 윈도우/예비 객실/중복 dedup 자동 처리 명시 + 회복 시나리오 보강
- docs/features/foresttrip-vacancy.md: 기본 흐름 6단계와 주의할 점 보강

사용자 라이브 검증 ground truth (2026-05-12 기준):
- 거제자연휴양림 5/13 ~9개, 5/16 0개, 5/17 19개, 5/23 0개, 5/24 0개
- 구재봉자연휴양림 5/16 1개 (206호 쑥부쟁이방)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:57:25 +09:00
Jeffrey (Dongkyu) Kim
3d8008f2f2 Harden KTX seat detail trust boundaries
Fail closed when Korail reports remaining seats but returns malformed, sentinel-only, or schema-incomplete detail rows, and keep the seat research endpoints inside the same Dynapath/Sid request boundary as other protected mobile calls.\n\nConstraint: PR #298 review required TDD regressions for false empty-seat success and raw external payload failures.\nRejected: Preserve lenient normalization of partial seat rows | it can mislead automation with authoritative empty results.\nConfidence: high\nScope-risk: narrow\nDirective: Keep KTX seat detail parsing fail-closed unless live Korail evidence proves a specific empty or partial shape is valid.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci\nNot-tested: live Korail seat-detail network flow with production credentials
2026-05-29 01:55:52 +09:00
Jeffrey (Dongkyu) Kim
f8401ef405 Fail clearly at KTX seat payload boundaries
Boundary validation now distinguishes unavailable/malformed Korail car and seat-detail payloads from valid zero-seat results, so automation does not consume false successful empty-seat JSON.\n\nConstraint: PR #298 follow-up for issue #293 required TDD, clear errors for malformed details/metadata, and branch feature/#293.\nRejected: Per-car partial error payloads | broader interface change than the existing fail-fast command contract.\nRejected: Missing Korail car counts defaulting to zero | masks unknown external summary data as authoritative no-seat state.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail and car-summary schema checks at the command boundary before normalizing rows.\nTested: Targeted malformed payload probe; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci.\nNot-tested: Live Korail seat-detail endpoints requiring user credentials.
2026-05-29 01:41:33 +09:00
Jeffrey (Dongkyu) Kim
1d4b2eb723 Keep KTX seat lookup typing from obscuring review signals
The seat inspection follow-up already fixed the runtime schema issues, but review still had changed-area pyright noise around the dynamic Korail boundaries. Route those calls through explicit narrow dynamic seams so the remaining pyright output stays confined to the existing baseline.\n\nConstraint: PR #296 follow-up must preserve the existing korail2 dynamic runtime behavior while cleaning the reviewed changed area.\nRejected: Broad pyright baseline cleanup | outside issue #292 scope and higher risk for this follow-up.\nConfidence: high\nScope-risk: narrow\nDirective: Keep future Korail mobile endpoint additions behind explicit typed/dynamic boundaries so changed-area diagnostics remain actionable.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help; pyright changed-area grep against search/seat lines\nNot-tested: live authenticated Korail seat lookup; full pyright remains on existing dynamic Korail/test-helper baseline
2026-05-29 01:30:33 +09:00
Jeffrey (Dongkyu) Kim
002c6c13bc Make KTX seat summaries match active filters
Constraint: PR #296 follow-up must address reproducible review findings without live Korail credentials.
Rejected: Keeping available_seats unfiltered | It conflicted with --power-only user intent and review feedback.
Confidence: high
Scope-risk: narrow
Directive: Keep filtered availability and all_available_* semantics documented together when changing seats output.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: live authenticated Korail seat lookup
2026-05-29 01:00:27 +09:00
Jeffrey (Dongkyu) Kim
20e8f3f8f0 Expose missing KTX seat detail payloads
Constraint: PR #298 review blocker required missing seat_infos.seat_info to fail clearly instead of reporting zero seats.
Rejected: Treating absent seat_info as an empty list | this masks Korail detail endpoint failures as authoritative no-seat results.
Confidence: high
Scope-risk: narrow
Directive: Preserve explicit empty seat_info lists as valid; only absent or malformed detail schema should fail.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PYENV_VERSION=3.11.9 npm run ci
Not-tested: plain npm run ci with local default Python 3.14 remains blocked by pyexpat/libexpat linkage before project tests
2026-05-29 00:27:02 +09:00
Jeffrey (Dongkyu) Kim
eb08ef6134 Keep malformed KTX seat payloads actionable
Convert malformed Korail seat-detail payloads into the existing CLI failure path so advisory lookup callers get a clear retryable error instead of an AttributeError.

Constraint: PR #295 review watch item identified successful-but-malformed seat_infos payloads as an unhelpful crash surface.

Rejected: Letting raw adapter fallbacks leak AttributeError | CLI users need actionable SystemExit diagnostics at the command boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep detailed seat lookup advisory; validate raw Korail shapes before exposing fields to JSON callers.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; node --test scripts/skill-docs.test.js; npm run typecheck; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; ruff check scripts/ktx_booking.py scripts/test_ktx_booking.py; shellcheck scripts/validate-skills.sh; bash scripts/validate-skills.sh; PATH=<pyenv 3.11.9 shim> npm run ci

Not-tested: Plain npm run ci with /opt/homebrew Python 3.14 due local pyexpat/libexpat linkage error reproduced before project tests.
2026-05-29 00:11:29 +09:00
iamiks
1d8fd333d8 Expose KTX malformed seat detail evidence
Keep seat-priority output from reporting upstream seat-detail schema failures as authoritative zero-seat availability. Preserve car-level remaining_seats and surface seat_lookup_error when detail payloads are malformed while seats remain.

Constraint: PR #295 review follow-up keeps advisory priority/filter behavior while avoiding false zero-seat results.

Rejected: Treat malformed or empty detail payloads as [] | masks Korail endpoint/schema failures as no availability.

Confidence: high

Scope-risk: narrow

Directive: Keep detailed seat lookup advisory unless reserve gains explicit car/seat selection support.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; git diff --check origin/dev...HEAD; npm run typecheck

Not-tested: live authenticated Korail seat-detail lookup
2026-05-29 00:09:29 +09:00
Jeffrey (Dongkyu) Kim
95c6222a65 Fail early on incomplete KTX seat lookup context
Constraint: PR #296 review watchlist noted raw Korail mobile context can otherwise send empty residual-seat payload fields upstream.
Rejected: Keep empty-string defaults in residual-seat payloads | That hides stale or malformed search detail context until the anti-bot-sensitive endpoint is called.
Confidence: high
Scope-risk: narrow
Directive: Keep residual-seat payload requirements explicit if Korail changes the mobile h_* field contract.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: Live Korail authenticated seat lookup; credentials are not available in automation.
2026-05-29 00:06:55 +09:00
Jeffrey (Dongkyu) Kim
48a0420752 Expose KTX malformed seat detail evidence
Preserve car-level remaining_seats when Korail detail payloads drift, and mark the affected car with an explicit lookup error instead of reporting a false zero-seat result.\n\nConstraint: PR #293 review required malformed detail responses to be visible when car summary still reports remaining seats.\nRejected: silently normalizing malformed seat_infos to [] | it hides upstream schema drift as legitimate zero availability.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail schema drift observable; do not collapse positive remaining seats and malformed detail payloads into successful empty results.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py\nTested: node --check scripts/skill-docs.test.js\nTested: node --test scripts/skill-docs.test.js\nNot-tested: live Korail seat-detail endpoint with production credentials
2026-05-29 00:06:55 +09:00
Jeffrey (Dongkyu) Kim
6a26a194c6 Fail early on incomplete KTX seat lookup context
Constraint: PR #296 review watchlist noted raw Korail mobile context can otherwise send empty residual-seat payload fields upstream.
Rejected: Keep empty-string defaults in residual-seat payloads | That hides stale or malformed search detail context until the anti-bot-sensitive endpoint is called.
Confidence: high
Scope-risk: narrow
Directive: Keep residual-seat payload requirements explicit if Korail changes the mobile h_* field contract.
Tested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js; node --test scripts/skill-docs.test.js; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help
Not-tested: Live Korail authenticated seat lookup; credentials are not available in automation.
2026-05-28 23:59:49 +09:00
Jeffrey (Dongkyu) Kim
6aff1e7301 Expose KTX malformed seat detail evidence
Preserve car-level remaining_seats when Korail detail payloads drift, and mark the affected car with an explicit lookup error instead of reporting a false zero-seat result.\n\nConstraint: PR #293 review required malformed detail responses to be visible when car summary still reports remaining seats.\nRejected: silently normalizing malformed seat_infos to [] | it hides upstream schema drift as legitimate zero availability.\nConfidence: high\nScope-risk: narrow\nDirective: Keep seat-detail schema drift observable; do not collapse positive remaining seats and malformed detail payloads into successful empty results.\nTested: PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking\nTested: python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py\nTested: node --check scripts/skill-docs.test.js\nTested: node --test scripts/skill-docs.test.js\nNot-tested: live Korail seat-detail endpoint with production credentials
2026-05-28 23:42:10 +09:00
iamiks
70b92d6b03 Clarify KTX seat filter failures
Constraint: PR #295 review found power-only summaries included non-power seats and empty car-list payloads looked successful.
Rejected: Document available_seats as unfiltered | would preserve a surprising JSON contract for power-only callers.
Rejected: Ignore local hidden directories one by one | hidden runtime/cache directories can keep reappearing outside git.
Confidence: high
Scope-risk: narrow
Directive: Keep seats output summaries aligned with active display filters, while --limit remains a detailed seats display cap.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m compileall -q scripts/ktx_booking.py scripts/test_ktx_booking.py; git diff --check; node --test scripts/skill-docs.test.js; npm run typecheck; PIP_BREAK_SYSTEM_PACKAGES=1 npm run ci
Not-tested: live Korail production seat endpoint behavior
2026-05-28 23:07:09 +09:00
iamiks
9b9f5bc7a2 Stabilize KTX seat lookup review fixes
Constraint: PR #293 review found fallback korail2 imports failed under PYTHONNOUSERSITE and seat lookup needed clearer Dynapath/power-seat contracts.
Rejected: Leave research endpoints outside DYNAPATH_PATHS | would keep the auth-header behavior ambiguous for live seat lookup.
Confidence: high
Scope-risk: narrow
Directive: Keep fallback-only tests independent of installed korail2 so CI catches missing dependency behavior.
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; PYTHONPATH=.:scripts PYTHONNOUSERSITE=1 python3 -m unittest discover -s scripts -p 'test_ktx_booking.py'; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; PYTHONPATH=.:scripts python3 scripts/ktx_booking.py seats --help; node --test scripts/skill-docs.test.js
Not-tested: live Korail seat lookup against production endpoints
2026-05-28 22:59:24 +09:00
iamiks
ee51dbc2f1 chore(ci): 로컬 런타임 디렉터리 검증 제외
로컬 개발 환경에서 생성되는 .agents와 .venv를 최상위 스킬로
검사하지 않도록 제외해 skill layout 검증을 실제 스킬 디렉터리에 한정합니다.

Constraint: npm run ci가 validate-skills.sh를 필수로 실행함
Rejected: 로컬 .agents/.venv 삭제 | 사용자 환경 파일을 제거하는 방식은 부적절함
Confidence: high
Scope-risk: narrow
Directive: 새 로컬 런타임 디렉터리가 생기면 skill-docs.test.js 제외 목록과 함께 검토할 것
Tested: npm run ci
2026-05-28 22:49:31 +09:00
iamiks
24feb3edca feat(ktx-booking): 좌석 탐색 우선순위 개선
KTX 상세 좌석 조회에서 가운데 호차를 먼저 탐색하고,
같은 호차 안에서는 콘센트 좌석과 순방향 좌석을 우선 노출합니다.
#294 요구사항을 우선순위 함수와 command_seats 회귀 테스트로 고정합니다.

Constraint: #294는 기존 seats 공개 인터페이스 유지를 요구함
Rejected: 예약 API에 객차/좌석 번호를 직접 주입 | 현재 helper 예약 경로가 해당 선택 인자를 노출하지 않음
Confidence: high
Scope-risk: narrow
Directive: 좌석 우선순위 변경 시 command_seats 출력 순서 테스트를 함께 갱신할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking
Tested: node --test scripts/skill-docs.test.js
Tested: npm run typecheck
Tested: npm run ci
2026-05-28 22:49:31 +09:00
iamiks
8420792a82 Expose KTX seat-level lookup before reservation
Add a seats command to inspect residual seats by car, normalize seat metadata, and surface power-outlet hints while preserving the existing search and reserve selector flow. Update skill docs so seat-number and good-seat requests route through the detailed lookup before reservation.

Constraint: Korail reservation remains separate from seat inspection; the new flow must not select, hold, or pay for seats.

Rejected: Reusing train-level has_general_seat flags for good-seat requests | those flags cannot answer car number, seat label, or outlet availability.

Confidence: high

Scope-risk: moderate

Directive: Keep search, seats, and reserve on the same train_type and stable train_id path so stale results fail explicitly.

Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking; python3 -m py_compile scripts/ktx_booking.py scripts/test_ktx_booking.py; node --check scripts/skill-docs.test.js

Not-tested: npm run ci is blocked locally because gitignored .agents/ and .venv directories are treated as missing SKILL.md by scripts/validate-skills.sh.
2026-05-28 22:49:13 +09:00
seungwonme
3ba8899f63 feat(plugin): Claude Code 플러그인 + 마켓플레이스 매니페스트 추가
디렉토리 구조와 npm/changesets 릴리스 흐름을 건드리지 않고 루트의
한국 특화 Agent Skill 모음을 Claude Code 플러그인으로 설치 가능하게 한다.

- .claude-plugin/plugin.json: skills 배열로 루트 89개 skill을 노출하는 단일 번들
  (지원 중단된 blue-ribbon-nearby 제외)
- .claude-plugin/marketplace.json: repo 자체를 마켓플레이스로, 번들 플러그인 1개(source ./)
- scripts/generate-plugin-manifest.js: SKILL.md 스캔으로 skills 배열 생성, --check drift 게이트
- scripts/test_generate_plugin_manifest.js: discovery/제외/drift node --test 6케이스
- scripts/validate-skills.sh: dot 디렉토리 개별 나열을 '! -name .*'로 일반화 (.claude-plugin 오인 방지)
- package.json: generate:plugin-manifest 스크립트 + lint/test 등록, lint 끝에 drift 게이트
- README.md: 플러그인 설치 안내 추가

설치: /plugin marketplace add NomaDamas/k-skill -> /plugin install k-skill@k-skill
2026-05-27 23:27:56 +09:00
TaeyoungPark
9b2e0957f2 chore: merge upstream/dev into feat/myrealtrip-mcp-search 2026-05-27 16:33:36 +09:00
TaeyoungPark
0e30b79e83 fix: stabilize startup-support deadline filtering and tests 2026-05-27 16:30:29 +09:00
TaeyoungPark
807fa0c900 feat: Add startup-support API routes to k-skill-proxy
- Add startup-support API routes for Korean government startup programs
- Implement /v1/startup-support/list, /detail, /region, /deadline endpoints
- Integrate with existing k-skill-proxy infrastructure

Closes #startup-support
2026-05-27 15:41:11 +09:00
TaeyoungPark
d12bfa1fab feat: Add startup-support skill for Korean government startup programs
- Add startup-support skill to search Korean government startup support programs
- Implement Python script with multiple data sources (public data, local governments)
- Add k-skill-proxy routes for API endpoints
- Update documentation (README.md, docs/features/, docs/sources.md, etc.)
- Add comprehensive test suite

Closes #startup-support
2026-05-27 15:40:12 +09:00
Jeffrey (Dongkyu) Kim
0ea646a03d
Merge pull request #287 from NomaDamas/dev
ci(deploy): naverMapConfigured smoke test 예외 처리
2026-05-26 10:17:00 +09:00
Jeffrey (Dongkyu) Kim
abe26e411d ci(deploy): skip naverMapConfigured in smoke test until NCP keys are provisioned 2026-05-26 09:58:37 +09:00
Jeffrey (Dongkyu) Kim
234790a7ea
Merge pull request #286 from NomaDamas/changeset-release/main
chore: version packages
2026-05-26 09:53:29 +09:00
github-actions[bot]
d2db629640 chore: version packages 2026-05-26 00:52:47 +00:00
Jeffrey (Dongkyu) Kim
0300e9b91b
Merge pull request #285 from NomaDamas/dev
release: dev → main 통합 릴리스 (2026-05-25)
2026-05-26 09:51:49 +09:00
Jeffrey (Dongkyu) Kim
19af47399d merge: resolve conflicts with main (keep dev action versions + lint entries) 2026-05-26 09:33:04 +09:00
Jeffrey (Dongkyu) Kim
99340393de docs: 네이버맵 스킬 미작동 안내 추가 (NCP 키 미설정) 2026-05-25 18:31:03 +09:00
Jeffrey (Dongkyu) Kim
e90897a684
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
feat(#267): 카카오맵 스킬 (Kakao Local 장소검색 + Kakao Mobility 자동차 길찾기)
2026-05-25 17:18:27 +09:00
Jeffrey (Dongkyu) Kim
72a3fd7ca6 merge: resolve conflicts with dev after PR #282 merge 2026-05-25 17:16:53 +09:00
Jeffrey (Dongkyu) Kim
440f33b52f
feat(#268): 네이버맵 길찾기 MVP 스킬 + NCP Maps proxy 라우트 3종
feat(#268): 네이버맵 길찾기 MVP 스킬 + NCP Maps proxy 라우트 3종
2026-05-25 16:39:51 +09:00
Jeffrey (Dongkyu) Kim
ed30f22f86
fix(ci): add npm auth preflight to release workflow
fix(ci): add npm auth preflight to catch publish permission failures early
2026-05-25 15:19:04 +09:00
Jeffrey (Dongkyu) Kim
5e58d1fe86 fix(ci): add npm auth preflight to release workflow
The latest npm release (changeset publish) failed with E404 on 4 packages:
daiso-product-search, sh-notice-search, emergency-room-beds,
local-election-candidate-search.

Root cause: NPM_TOKEN lost publish access to these packages
(previously worked on May 19; failed on May 22).

This commit adds two preflight steps before changeset publish:
1. npm whoami — fail fast if the token is invalid or expired
2. npm access check — verify publish rights for every package
   whose local version differs from npm registry, before the
   changeset action runs

The actual publish fix requires rotating or re-authorizing the
NPM_TOKEN secret in GitHub → Settings → Secrets. Once the token
is valid, re-trigger the workflow to publish the 4 stuck packages.
2026-05-25 13:44:08 +09:00
Jeffrey (Dongkyu) Kim
51ea778a2d Keep Kakao waypoint validation at the proxy boundary
Constraint: Kakao Mobility waypoint coordinates share the same x,y shape as origin and destination.\nRejected: Letting out-of-range waypoints reach upstream | it spends quota on a deterministic bad request.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Kakao Mobility coordinate validation local before cache lookup or upstream fetch.\nTested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject invalid waypoint 400/0 upstream calls and valid waypoint 200/1 upstream call.\nNot-tested: npm run ci full root pipeline, because prior PR validation documented a local Python 3.14 pyexpat/pip install environment blocker.
2026-05-23 21:56:35 +09:00
Jeffrey (Dongkyu) Kim
2dbad40078 Keep Kakao radius filters local
Reject keyword radius without a coordinate center before Kakao Local calls so predictable client errors do not spend upstream quota.\n\nConstraint: PR #283 review round 3 requested local radius validation for issue #267.\nRejected: Letting Kakao Local reject radius-only keyword searches | wastes quota and weakens proxy determinism.\nConfidence: high\nScope-risk: narrow\nDirective: Keep coordinate-centered Kakao filters validated before cache lookup or upstream fetch.\nTested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke.\nNot-tested: npm run ci remains blocked in local Python 3.14 pyexpat during pip install beautifulsoup4 after lint/typecheck.
2026-05-23 21:34:38 +09:00
Jeffrey (Dongkyu) Kim
68bd64ebd4 Preserve route proxy rate-limit semantics
Narrow the Naver Maps proxy contract to JSON reverse geocode responses and preserve upstream quota signals so client fallback can make accurate decisions.

Constraint: PR #282 review requested TDD fixes for XML contract mismatch, upstream 429 mapping, lint coverage, and route option documentation.

Rejected: XML passthrough in this follow-up | It would require a separate response-shaping contract and tests beyond the JSON proxy boundary.

Confidence: high

Scope-risk: narrow

Directive: Keep Naver Maps auth failures sanitized as 503 without upstream body snippets while preserving non-auth diagnostic snippets.

Tested: node --test packages/k-skill-proxy/test/server.test.js; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; PYENV_VERSION=3.12.0 npm run ci; architect verification CLEAR

Not-tested: Live NCP Maps calls with production credentials

Co-authored-by: OmX <omx@oh-my-codex.dev>
2026-05-23 19:57:21 +09:00
Jeffrey (Dongkyu) Kim
366d346f03 Keep Kakao route contracts local and explicit
Constraint: PR #283 review requested TDD fixes for Kakao Local distance sorting, Mobility toll avoidance, lint coverage, and coord2region routing coverage.
Rejected: Relying on upstream Kakao validation for sort=distance | it spends quota and returns a proxy/upstream error instead of local bad_request.
Rejected: Document-only toll avoidance correction | the skill already promises the behavior and Kakao Mobility exposes an explicit avoid option.
Confidence: high
Scope-risk: narrow
Directive: Preserve server-side KAKAO_REST_API_KEY injection only; never accept or forward caller apiKey query values.
Tested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke for sort=distance and avoid forwarding; npm run ci through lint/typecheck until local Python pyexpat failure.
Not-tested: Full npm run ci completion due local Python 3.14 pyexpat ImportError during pip install.
2026-05-23 19:25:10 +09:00
Jeffrey (Dongkyu) Kim
73c3611e8a Protect Naver Maps credential boundary
Sanitize auth-failure upstream bodies while retaining non-auth diagnostics for operator debugging.

Constraint: PR #282 review requires Naver Maps 401/403 bodies to be hidden from public callers
Rejected: Blanket removal of all upstream snippets | non-auth 5xx diagnostics are still useful and covered by regression
Confidence: high
Scope-risk: narrow
Directive: Keep 401/403 response bodies out of public Naver Maps proxy payloads
Tested: node --test packages/k-skill-proxy/test/server.test.js; PYENV_VERSION=3.12.0 npm run ci; mocked app injection for 401 response
Not-tested: Live NCP Maps auth failure against production credentials
2026-05-23 18:07:19 +09:00
Jeffrey (Dongkyu) Kim
45084293f0
Feature/#270 (#281)
* Enable deterministic Middle Korean-style rewriting

Constraint: Issue #270 requested a new skill that converts incoming Korean text into 한국 중세 국어 style under non-interactive TDD automation.
Rejected: LLM-only prompt guidance | It would not provide deterministic CLI behavior or regression-testable output.
Confidence: high
Scope-risk: narrow
Directive: Keep this as creative style conversion, not an academically exact Middle Korean translator.
Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; root node/python/workspace tests without pip bootstrap; npm run pack:dry-run; installed-skill smoke.
Not-tested: npm run test bootstrap step because python3 -m pip fails in this local Homebrew Python 3.14 environment due pyexpat/libexpat symbol mismatch before tests start.

* Align Middle Korean profile contract with implementation

Preserve the existing date-before-lexicon transform order and document it as the v1 contract instead of reordering an already-reviewed helper.

Constraint: PR #281 review requested the docs/contract mismatch be resolved with TDD evidence.

Rejected: Reordering the converter | would alter current output behavior beyond the approved follow-up.

Confidence: high

Scope-risk: narrow

Directive: Treat middle-korean-style-v1 output-changing rule order edits as contract changes that need regression tests and docs updates.

Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; npm run pack:dry-run; npm run test without pip bootstrap commands; installed-skill smoke.

Not-tested: npm run test direct bootstrap remains blocked locally by Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* Clarify middle Korean profile stability

Align the documented v1 contract with the intentionally broad deterministic replacer so future readers do not infer exact proper-noun preservation.

Constraint: PR #281 round 2 architect WATCH asked to weaken preservation guarantees or add stronger rule boundaries.

Rejected: Changing replacement behavior | The PR already verified the creative v1 output and only the contract wording was mismatched.

Confidence: high

Scope-risk: narrow

Directive: Treat output-changing edits to middle-korean-style-v1 as compatibility-affecting and update docs plus regression tests together.

Tested: node --test scripts/test_korean_middle_korean.js; node --check korean-middle-korean/scripts/korean_middle_korean.js; node --check scripts/korean_middle_korean.js; npm run lint; npm run typecheck; root/workspace post-bootstrap test chain; npm run pack:dry-run; installed skill smoke.

Not-tested: Direct npm run test still blocked before tests by local Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* Protect structural spans during style conversion

Keep arbitrary text links, email addresses, and code spans usable while preserving the deterministic middle-korean-style-v1 prose transform.\n\nConstraint: PR #281 round 3 requested URL/email/code-like span protection after broad global replacement probes rewrote structural tokens.\nRejected: Narrowing all lexicon and particle rules with word-boundary heuristics | would change established v1 creative broad-replacement behavior beyond the reviewed issue.\nConfidence: high\nScope-risk: narrow\nDirective: Protect new structural span classes before broad replacements and add regression tests before extending the protected surface.\nTested: node --test scripts/test_korean_middle_korean.js; node --check korean-middle-korean/scripts/korean_middle_korean.js; node --check scripts/korean_middle_korean.js; CLI URL/email/code/Markdown probes; installed-skill smoke via ~/.agents/skills/korean-middle-korean/scripts/korean_middle_korean.js; npm run lint; npm run typecheck; root/workspace test chain without pip bootstrap; npm run pack:dry-run; post-deslop npm run lint && npm run typecheck && node --test scripts/test_korean_middle_korean.js && npm run pack:dry-run\nNot-tested: direct npm run test remains blocked before repo tests by local Homebrew Python 3.14 pyexpat/libexpat import error.
2026-05-23 17:54:56 +09:00
Jeffrey (Dongkyu) Kim
6d49a28d87 feat(kakao-map): Kakao Local/Mobility 프록시 라우트 + 장소·자동차 길찾기 스킬 (#267)
- packages/k-skill-proxy:
  - /v1/kakao-map/search/keyword (좌표 중심·반경·카테고리 필터)
  - /v1/kakao-map/search/category (좌표 중심 필수, FD6/CE7 등 공식 코드 화이트리스트)
  - /v1/kakao-map/coord2address (좌표→도로명/지번)
  - /v1/kakao-map/coord2region (좌표→법정동/행정동)
  - /v1/kakao-mobility/directions (자동차 길찾기, priority/car_fuel/waypoints/alternatives 옵션)
  - 모두 운영자 KAKAO_REST_API_KEY 서버측 주입, caller apiKey 무시
- kakao-map 스킬 + docs/features/kakao-map.md 신규
- proxy 테스트 10건 신규 (헤더 주입·캐시, 좌표·반경·정렬·카테고리·priority 검증, 503 missing-key 매트릭스, semantic failure non-cache, health 플래그)
- README/포함된 기능, packages/k-skill-proxy/README, docs/sources, changeset 동시 갱신

Closes #267
2026-05-23 17:51:06 +09:00
Jeffrey (Dongkyu) Kim
ff2aa91f83 feat(naver-map-route): NCP Maps Directions/Geocode/Reverse-Geocode 프록시 라우트 + MVP 길찾기 스킬 (#268)
- packages/k-skill-proxy: NAVER_MAP_CLIENT_ID/SECRET 서버측 보관, /v1/naver-map/{directions,geocode,reverse-geocode} 라우트 3종 추가
- naver-map-route: instruction-level MVP 스킬 (mock 기본, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true + ROUTE_PLANNER_PROVIDER=naver 에서만 live)
- /route, /이동루트 수동 입력 처리, graceful fallback 정책 문서화
- proxy 테스트 8건 신규 (missing-key 503, 캐시, 좌표 검증, semantic failure non-cache, auth error sanitize, geocode 헤더 주입, reverse-geocode orders 검증, health 플래그)
- README 표/포함된 기능, packages/k-skill-proxy/README, docs/features/naver-map-route, docs/sources, changeset 동시 갱신

Closes #268
2026-05-23 17:36:55 +09:00
github-actions[bot]
6551004967
chore: version packages (#278)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-22 11:42:03 +09:00
Jeffrey (Dongkyu) Kim
01cd887579
release: dev → main — Cloud Run 자동 배포 전환 + 신규 스킬 다수 (#276)
* docs(flight-ticket-search): register skill in README table and add feature guide

PR #224 머지 시 README "어떤 걸 할 수 있나" 표와 "포함된 기능" 리스트, 그리고
docs/features/flight-ticket-search.md 가이드가 등록되지 않아 main에 있는 다른
모든 스킬과 달리 사용자/에이전트가 README만 봐서는 이 스킬을 발견할 수 없는
상태였다. 누락분을 hotfix로 보강한다.

- README 표에 `flight-ticket-search` 행 추가 (마이리얼트립 옆 항공 클러스터)
- README "포함된 기능" 리스트에 가이드 링크 추가
- docs/features/flight-ticket-search.md 신규 작성:
  · 사용 시나리오, 구현 표면(fast-flights==2.2, 사용자 venv 격리)
  · search / compare-month / compare-range / compare-years CLI 예시
  · 응답 필드, IATA 입력 가이드, 예약 링크 정책
  · 검증된 노선 목록, 실패 모드, 비범위, 출처

검증:
- node --test scripts/skill-docs.test.js → 138/138 pass
- ./scripts/validate-skills.sh → skill layout looks valid

코드 변경 없음 → changeset 불필요.

* feat(daiso-product-search): replace blocked-API fallback with Bearer token auth

selStrPkupStck는 더 이상 차단 상태가 아니며, /api/auth/request로 비로그인 JWT를
발급받아 AES-128-CBC(키: PRE_AUTH_ENC_KEY)로 암호화한 Bearer 토큰으로 접근한다.
403 응답 시 토큰을 재발급해 1회 재시도한다. pickupEligibility(selPkupStr) 폴백
로직은 제거했다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Preserve Daiso pickup answers when Bearer auth degrades

Keep exact stock lookup on the official Bearer-token path while restoring the public selPkupStr fallback for repeated auth blocks.

Constraint: PR #250 review required Bearer auth to remain primary without removing the resilient pickup eligibility API.

Rejected: Throwing after the retry | it collapses callers back to a brittle single upstream-auth dependency.

Confidence: high

Scope-risk: narrow

Directive: Keep pickupStock quantity semantics separate from pickupEligibility yes/no fallback.

Tested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.

Not-tested: Live forced 403 from Daiso upstream; covered with injected fetch regression tests.

* Prove Daiso stock retry sends auth headers

Strengthen the retry regression so the Bearer-token contract cannot regress while still returning success from mocked stock responses.\n\nConstraint: PR #250 review requested explicit Authorization, X-DM-UID, and request body assertions on the retry path.\nRejected: Counting requests only | it allowed header/body regressions to pass.\nConfidence: high\nScope-risk: narrow\nDirective: Keep auth-header assertions on both initial and retry stock requests when editing this flow.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100; repeated-403 fixture probe.\nNot-tested: Live repeated upstream 403 because forcing Daiso production auth failure is not available without changing upstream state.

* Preserve Daiso caller headers through Bearer stock lookup

Keep advanced caller headers on the authenticated stock endpoint while generated Bearer and X-DM-UID values remain authoritative. Document the degraded selPkupStr fallback order in skill and source docs so the public workflow matches the restored API surface.\n\nConstraint: PR #250 review required resilient Bearer-primary stock lookup plus selPkupStr fallback and header/body contract coverage.\nRejected: Replacing caller headers with only auth headers | It regressed tracing/test-control header pass-through.\nConfidence: high\nScope-risk: narrow\nDirective: Keep Authorization and X-DM-UID generated by the auth flow even when callers provide same-named headers.\nTested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; node --test scripts/skill-docs.test.js; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.\nNot-tested: Forced live upstream repeated 403; covered by injected fixture tests.

* fix(danawa-price-search): capture .ico.* payment-condition badges and surface as row labels

PR #226 row 파서에 결제조건 배지(`.ico.cash`/`.ico.point`/`.ico.coupon`/`.ico.card`) selector가 누락돼, 카드 결제 불가능한 현금/쿠폰/포인트 전용가가 일반 최저가로 노출되는 결함을 고친다.

- `offers()` row 파싱부에 결제조건 배지 화이트리스트 캡처 블록 추가 (클래스 `cash`/`point`/`coupon`/`discount`/`card`/`membership` 또는 텍스트 `현금`/`포인트`/`쿠폰`/`할인`만 인정 — 빠른배송/안내/상품리뷰 노이즈 차단)
- row dict 신규 필드 6개: `payment_badges`, `cash_only`, `point_only`, `coupon_only`, `card_only_badge`, `is_conditional_price`
- 반환 dict에 `normal_count`, `conditional_count` 추가
- `SKILL.md` / `docs/features/danawa-price-search.md` 갱신 (Output shape · Response style · Workflow · Failure modes에 결제조건 정책과 표 예시 명시)

정렬 정책은 그대로 `total_price` 단일 기준이며, 결제조건은 row 단위 플래그/라벨로만 노출해 호출자가 결제수단에 맞춰 직접 판단하도록 한다.

회귀 (pcode=75001853, 갤럭시 S25 256GB 자급제 `offers --limit 5`):
- 1위 킴스클럽 979,000원 / `cash_only=True` / `payment_badges=["현금"]`
- 2위 롯데ON 1,072,080원 / `cash_only=False` / `payment_badges=[]`
- 3~5위 일반가 row 모두 `payment_badges` 빈 리스트 (노이즈 0건)

Closes #252

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

* Ensure captured Danawa payment badges stay conditional

Classify every whitelisted payment badge into normalized condition types so callers cannot count captured discount, membership, or text-only card rows as normal prices.

Constraint: PR #253 review required TDD follow-up on feature/#252 without changing total_price sorting.\nRejected: Removing discount and membership from the whitelist | would lose Danawa condition labels already captured by the parser.\nConfidence: high\nScope-risk: narrow\nDirective: Keep payment_badge whitelist and payment_condition_types in sync whenever adding new badge classes or text keywords.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; live offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test; architect verification CLEAR.\nNot-tested: Danawa markup variants not represented by current live page or synthetic badge fixtures.

* Keep icon-only Danawa payment badges visible

Class-only Danawa payment icons can carry eligibility information without visible text, so synthesize display labels from the same normalized condition map used for types and booleans. This keeps raw row labels, condition fields, and returned-window counts aligned for downstream table renderers.\n\nConstraint: PR #253 review follow-up requires TDD coverage before parser changes.\nRejected: Leaving payment_badges text-only | icon-only conditional rows would still render without visible payment labels.\nConfidence: high\nScope-risk: narrow\nDirective: Derive future payment badge labels, types, and booleans from one canonical mapping.\nTested: python3 -m py_compile danawa-price-search/scripts/danawa_search.py scripts/test_danawa_price_search.py; PYTHONPATH=.:scripts python3 -m unittest scripts.test_danawa_price_search; python3 danawa-price-search/scripts/danawa_search.py offers 75001853 --limit 5; npm run lint; npm run typecheck; npm run test\nNot-tested: Danawa icon-only markup was verified with synthetic fixtures rather than a live page snapshot.

* Merge pull request #249 from NomaDamas/feature/#248

Feature/#248

* Restore SH notice lookup without proxy policy drift

Reintroduce SH notice search as a direct public HTML client so the skill complies with the free-API proxy boundary while preserving verifiable keyword, pagination, and attachment behavior.

Constraint: i-sh.co.kr board is public unauthenticated HTML, so k-skill-proxy must not host the scraper.\nRejected: Re-adding /v1/sh-notice proxy routes | public HTML scraping in proxy violates repository policy.\nConfidence: high\nScope-risk: moderate\nDirective: Keep SH public HTML access local/direct unless a key-required official free API is discovered and documented.\nTested: npm run ci; npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; live SH smoke for 행복주택, 매입임대, 신혼희망타운, page 1/page 5, 1/6/9/11/0 attachment details.\nNot-tested: authenticated SH flows, 청약 application/submission, direct attachment downloads.

* Preserve public SH helper semantics

Route exported URL builders through the same normalization as the CLI/API so natural category aliases cannot bypass srchTp title narrowing or category mapping.\n\nConstraint: PR #254 review found exported helper callers could pass Korean/English public category inputs and get broken or broadened SH URLs.\nRejected: Keep normalized-only fast paths | exported helpers are public API and must protect natural inputs.\nConfidence: high\nScope-risk: narrow\nDirective: Keep exported helper behavior aligned with normalizeSearchOptions and normalizeDetailOptions when adding new public aliases.\nTested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm run ci; node helper smoke for 임대 search/detail URLs.\nNot-tested: Live SH network smoke was not rerun for this helper-only change.

* Preserve SH parser helper aliases

Route exported parser helpers through the same public normalizers used by the SH fetch and URL-builder APIs so natural category aliases stay consistent across the package surface.

Constraint: PR #254 Round 2 review found parser helpers still treated raw category aliases as pre-normalized inputs.
Rejected: Keep parser helpers normalized-only | inconsistent with exported URL builders and public helper ergonomics.
Confidence: high
Scope-risk: narrow
Directive: Keep exported SH helper entry points on canonical normalizeSearchOptions/normalizeDetailOptions unless a separate internal-only API is introduced.
Tested: npm test --workspace sh-notice-search; npm run lint --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; parser smoke for Korean 임대 list/detail helpers; Ralph architect verification CLEAR; post-deslop regression npm run ci
Not-tested: Live SH network smoke for this follow-up; fixture and injected-fetch coverage exercised the helper contract.

* Make SH parser failures explicit

Warn when SH returns block or maintenance HTML without the expected public board markup, and constrain exposed preview links to the SH converter origin/path.\n\nConstraint: Round 3 review required TDD coverage for block/maintenance HTML and untrusted preview URLs.\nRejected: Throwing on unexpected HTML | Existing parser helpers return partial fixture-friendly results, so warnings preserve compatibility while exposing failure evidence.\nConfidence: high\nScope-risk: narrow\nDirective: Keep SH public HTML lookup direct; do not add proxy routing unless a key-required official free API is adopted.\nTested: npm run lint --workspace sh-notice-search; npm test --workspace sh-notice-search; npm run typecheck; npm pack --workspace sh-notice-search --dry-run; npm run ci; Node smoke for blocked HTML warnings and external preview filtering.\nNot-tested: Live blocked/NetFunnel SH response, because no live blocked page was available during implementation.

* ci: install beautifulsoup4 so danawa price search tests can import bs4

The new scripts/test_danawa_price_search.py imports danawa_search.py,
which requires beautifulsoup4. CI only runs npm ci, so the bs4 import
fails with 'beautifulsoup4 is required: python -m pip install
beautifulsoup4' and the validate job exits with code 1.

Install beautifulsoup4 via pip before running npm run ci so the
Python test suite can import danawa_search and run the new payment
badge regression tests.

* Revert "ci: install beautifulsoup4 so danawa price search tests can import bs4"

This reverts commit 8330e5adf7.

* test: install beautifulsoup4 inside npm test before Python tests

The new scripts/test_danawa_price_search.py imports danawa_search.py,
which requires beautifulsoup4. CI runs npm ci + npm run ci and does
not install Python packages, so the bs4 import fails at module load.

Install beautifulsoup4 via 'pip install --user' as the first step of
the test script so it is available when Python unittests import the
danawa helper. Local dev environments are unaffected because pip
install is idempotent and quiet.

* feat(qa-bot): add k-skill-qa-bot under tools/

External macOS daemon that clones NomaDamas/k-skill main every 3 days, runs
each skill through codex exec, has an LLM judge grade pass/fail/skip via
codex exec --output-schema, and files dedup'd GitHub issues for true failures.

Layout:
- install.sh copies tools/k-skill-qa-bot/ to ~/.local/share/k-skill-qa-bot/
  and registers a LaunchAgent at ~/Library/LaunchAgents/.
- update-clone.sh has a hard guard: refuses any K_SKILL_CLONE outside
  K_QA_HOME/k-skill-clone unless ALLOW_EXTERNAL_CLONE_TARGET=1.
- Force-skip 10 destructive/login-required skills (ktx-booking, srt-booking,
  catchtable-sniper, kakaotalk-mac, hipass-receipt, toss-securities, etc.)
  so the bot never triggers reservation abuse.
- Deprecated skills (strike-through + 지원 중단 in README) auto-detected
  and skipped, never failed.
- First-run safety: CREATE_ISSUES=false by default.
- mkdir-based concurrency lock with atomic stale reclaim.
- Issue dedup: sha1(skill_name + symptom_class)[:12] body marker.
- Deterministic gates override LLM judge to FAIL on exit_code != 0, missing
  VERDICT line, or near-timeout duration.

* Support nearby ER status checks

Add an E-Gen based emergency-room skill that resolves a user location, queries the public nearby emergency-room list, and reports operation flags while documenting that exact remaining bed counts are not exposed by this surface.

Constraint: Issue #255 requested NEMC emergency bed status using public monitoring/E-Gen surfaces.
Rejected: Scraping private monitoring dashboards or claiming exact bed utilization | public endpoints expose operation flags, not per-hospital remaining bed counts.
Confidence: high
Scope-risk: narrow
Directive: Preserve the public-data limitation text unless a verified official bed-count endpoint is added.
Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; live E-Gen coordinate smoke.
Not-tested: npm run ci end-to-end due local Python 3.14 pip/pyexpat import error before tests.

* Prevent ER status ambiguity from reaching users

Constraint: Health-adjacent public E-Gen/Kakao data can be absent, delayed, schema-drifted, or partially unknown.

Rejected: Mapping all non-Y operation flags to false | It misrepresents missing upstream data as a negative operating status.

Rejected: Treating unknown E-Gen payloads as empty results | It hides upstream failure behind a false no-results response.

Confidence: high

Scope-risk: narrow

Directive: Keep unknown health availability data explicit and preserve upstream failure evidence.

Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; direct Node smoke for tri-state/schema/coordinate guards.

Not-tested: npm run ci due pre-existing local Python 3.14 pyexpat/libexpat bootstrap failure noted on PR.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* fix(ci): exclude tools/ from skill validator

The tools/ directory hosts repo tooling (e.g. k-skill-qa-bot), not
skills, so validate-skills.sh should skip it like other non-skill
top-level directories.

* 영화관 검색 스킬 추가 (#260)

* Add korean cinema search skill

* Document playDate for cinema skill

* feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬 + 프록시 라우트 4종 (#259)

* feat(kstartup-search): 창업진흥원 K-Startup 조회 스킬과 프록시 라우트 추가

공공데이터포털 dataset 15125364 (창업진흥원_K-Startup(사업소개,사업공고,콘텐츠 등)_조회서비스) 의
4개 endpoint 를 k-skill-proxy 경유로 조회하는 스킬을 추가한다.

- 신규 라우트: GET /v1/kstartup/{business-info,announcements,contents,statistics}
  - 각각 getBusinessInformation01/getAnnouncementInformation01/getContentInformation01/
    getStatisticalInformation01 으로 중계
  - ServiceKey 는 서버 측 DATA_GO_KR_API_KEY 로 주입, returnType=json 강제
  - 정상 응답만 캐시, data.go.kr 에러 envelope (resultCode != "00", errMsg 등) 은 캐시 우회
- helper: kstartup-search/scripts/run_kstartup.py (stdlib only)
  - 일반 조회는 hosted proxy 사용 → 사용자 키 불필요
  - --direct 옵션은 사용자가 본인 KSKILL_KSTARTUP_API_KEY (혹은 DATA_GO_KR_API_KEY) 로
    upstream 직접 호출 + --dry-run 시 키 redact
- 입력 검증: page/perPage 정수·범위, YYYYMMDD 날짜 + 시작일 ≤ 종료일, Y/N 대문자화,
  텍스트 필드 길이 상한, biz_yr 4자리
- 테스트: k-skill-proxy 서버 테스트 10건 신규 (normalizer, 라우트, 캐시 분리,
  returnType=json 강제, 503/400/502, 키 누수 회귀), Python unittest 13건
- 문서: SKILL.md, docs/features/kstartup-search.md, README 표/리스트,
  docs/sources.md, .changeset/kstartup-search.md (k-skill-proxy minor)

* docs(kstartup-search): docs/setup·security·k-skill-setup·proxy README 에 K-Startup 항목 추가

seoul-density · KOSIS · NTS 선례와 동일한 위치·문구로 다음을 보강한다.

- docs/setup.md: dotenv 예시에 KSKILL_KSTARTUP_API_KEY 추가, credential 표에 K-Startup 행 추가, "다음에 볼 문서" 리스트 추가
- docs/security-and-secrets.md: standard variable names 에 KSKILL_KSTARTUP_API_KEY 추가, hosted proxy 사용 스킬 목록·proxy 운영 prose 에 K-Startup 추가, dotenv 예시 추가
- k-skill-setup/SKILL.md: credential resolution prose 와 시크릿 요약 표에 K-Startup 안내 추가
- packages/k-skill-proxy/README.md: 라우트 목록에 /v1/kstartup/{business-info,announcements,contents,statistics} 추가
- docs/features/k-skill-proxy.md: 라우트 목록에 같은 4개 추가

* fix(kstartup-search): strict calendar-date validation in Python helper

validate_yyyymmdd() previously only checked month in [1,12] and day in [1,31],
which accepted impossible dates like 20240230 or 20240431 in --direct mode.
The proxy-side normalizer in packages/k-skill-proxy/src/kstartup.js already
uses Date.UTC() to reject such inputs, so this aligns the --direct path with
the proxy path and eliminates validator drift.

Uses datetime.date(year, month, day) and raises HelperError on ValueError.

Adds regression test covering impossible calendar dates (Feb 30, Apr 31,
month 13, day 0) and the leap-year boundary (2024-02-29 valid, 2023-02-29
not).

---------

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

* fix(qa-bot): upgrade judge to gpt-5.5 and run codex with sandbox bypass

PR #257 follow-up. Two changes:

1. JUDGE_MODEL default: gpt-5.4-mini -> gpt-5.5

   The cheaper judge was misclassifying every wrong-output verdict because
   the offline matcher fell through to the dumb 'VERDICT: FAIL in transcript'
   check. Re-running the same 10 historical fail cases with gpt-5.5 +
   real LLM judge correctly reclassified 7 of them as pass (the codex agent
   actually accomplished the skill goal) and the remaining 3 as
   network-error / partial-success / skip with accurate reasons.

2. Drop -s read-only, add --dangerously-bypass-approvals-and-sandbox

   The read-only codex sandbox was triggering spurious DNS resolution
   failures inside the test runs (host blocked at the syscall level even
   for legitimate proxy / public-API calls). Live re-test with the bypass
   flag and provider pin produced clean transcripts: cheap-gas-nearby,
   daangn-realty-search, han-river-water-level, naver-news-search,
   naver-shopping-search, seoul-density, seoul-subway-arrival all PASS.
   The QA bot is sandboxed externally by launchd anyway.

3. New CODEX_PROVIDER env (default: openai)

   Lets users pin the codex model_provider explicitly so the bot does not
   accidentally route through a private OpenAI-compatible proxy that may
   not have keys registered for all model names.

* Add Ohou today deal skill

* fix spacing in package.json

* fix(qa-bot): per-skill test_prompt overrides and smarter judge

11 skills that need specific inputs (not just a 'demonstrate' query) now
ship with a hardcoded test_prompt in config/skill-overrides.yml:

  flight-ticket-search           ICN -> NRT, 2026-08-20 one-way
  nts-business-registration      124-81-00998 (Samsung Electronics)
  korean-stock-search            005930 Samsung 5-day quote
  joseon-sillok-search           키워드 훈민정음
  korean-law-search              산업안전보건법 제5조
  library-book-search            코스모스 칼 세이건
  lotto-results                  latest round
  k-schoollunch-menu             서울특별시교육청 초등학교 오늘 식단
  delivery-tracking              CJ dummy invoice (negative case ok)
  ticket-availability            YES24 / 인터파크 sample
  zipcode-search                 서울특별시 강남구 테헤란로 152

These were previously synthesized from the SKILL.md first When-to-use bullet,
which is a one-line teaser without concrete inputs. The agent would then
either ask the user for the missing input (partial-success) or fall back
to a generic demo (often producing a VERDICT: FAIL response). Both got
mis-classified as fail by the judge.

qa_utils.synthesize_test_prompt now honors default_inputs.test_prompt as a
verbatim override (only appending the VERDICT line if the override does not
already include it).

Two additional fixes for negative-case correctness:

1. judge-prompt.md: explicitly tells the judge that the agent's literal
   VERDICT: PASS / VERDICT: FAIL is just a hint, not binding. A skill that
   correctly returns 'no such business number' or 'invoice not found' for
   a deliberately invalid input is PASS, not fail.

2. judge-skill.py: drop the deterministic gate that flipped pass to fail
   when 'VERDICT: PASS' literal was missing from the transcript. That gate
   was producing false fails for negative-case tests where the agent
   correctly responded with VERDICT: FAIL because the skill rejected an
   invalid input. The judge LLM (gpt-5.5) is now trusted to evaluate the
   transcript against the SKILL.md 'Done when' criteria.

Verified live:

- nts-business-registration with valid number  -> pass/success (0.99)
- nts-business-registration with fake number   -> pass/success (0.99)
- flight-ticket-search ICN->NRT 2026-08-20     -> pass/success (0.99)

* fix(ohou-today-deal): address PR #264 review (live UA, explicit feed selection, argv validators)

- HIGH: switch fetch_html() to well-formed bot UA with contact URL
  (k-skill-ohou-today-deal/1.0 (+https://github.com/NomaDamas/k-skill)).
  ohou.se Akamai bot manager 403s anonymous UAs but allows identified
  bot UAs that include a contact URL. Live default workflow now returns
  74 deals end-to-end instead of failing with HTTP 403.
- MEDIUM: extract_deals() now explicitly selects React Query entries with
  queryKey == ['today-deal-feed'] or ['special-today-deal-feed'] and
  reads only state.data.todayDealFeed.slots[type=='DEAL']. Unrelated
  DEAL-shaped nodes from navigation/banner modules are excluded.
  Legacy fixture/JSON-payload fallback path preserved for tests that
  construct simplified payloads.
- LOW: --limit now requires a positive integer; --min-discount is
  constrained to 0..100. Both validated via argparse.ArgumentTypeError
  so users get a clear CLI error instead of silent slicing or nonsensical
  thresholds.
- Tests: add 9 new unit tests covering explicit feed selection,
  navigation/GOODS exclusion, fallback compatibility, and argv validators.
  Strengthen skill-docs.test.js to lock the special-today-deal-feed
  surface and well-formed UA signature.
- Docs: update SKILL.md and feature doc to document the explicit
  today-deal-feed + special-today-deal-feed extraction boundary and the
  Akamai UA policy.

* Merge pull request #263 from NomaDamas/feature/#257

Feature/#257

* Feature/#256 (#266)

* Enable public local-election candidate lookups

Add an NEC integrated-search skill and helper package so agents can answer 지방선거 후보자 lookup requests without credentials or proxy routes.

Constraint: Issue #256 requested TDD, Ralph completion, branch feature/#256, and PR targeting dev.

Rejected: k-skill-proxy route | NEC integrated candidate search is public and requires no API key.

Confidence: high

Scope-risk: moderate

Directive: Keep the helper read-only and do not automate NEC login, CAPTCHA, filing, or privileged election workflows.

Tested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; node packages/local-election-candidate-search/src/cli.js 오세훈 --election 시도지사 --region 서울 --limit 1; PATH=/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/Users/jeffrey/.codex/tmp/arg0/codex-arg0a6JueA:/opt/homebrew/lib/node_modules/@openai/codex/node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/path:/Users/jeffrey/.cmuxterm/omo-bin:/opt/homebrew/share/android-commandlinetools/platform-tools:/opt/homebrew/share/android-commandlinetools/emulator:/opt/homebrew/share/android-commandlinetools/cmdline-tools/latest/bin:/Users/jeffrey/.local/bin:/Users/jeffrey/.bun/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/opt/openjdk@21/bin:/opt/homebrew/opt/postgresql@18/bin:/Users/jeffrey/.jenv/shims:/Users/jeffrey/.jenv/bin:/opt/homebrew/opt/imagemagick/bin:/opt/homebrew/Cellar/pyenv-virtualenv/1.4.0/shims:/Users/jeffrey/.pyenv/shims:/opt/homebrew/opt/openssl@3/bin:/Users/jeffrey/.rbenv/shims:/Users/jeffrey/.rbenv/bin:/Users/jeffrey/google-cloud-sdk/bin:/Applications/cmux.app/Contents/Resources/bin:/Users/jeffrey/Library/pnpm:/Users/jeffrey/.nvm/versions/node/v24.13.0/bin:/Users/jeffrey/.cops/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/Library/TeX/texbin:/Users/jeffrey/.cargo/bin:/Users/jeffrey/Library/Application Support/JetBrains/Toolbox/scripts:/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home/bin:/Users/jeffrey/xcode-projects/marshroom/cli npm run ci

Not-tested: Exhaustive NEC markup variants for every historical election type.

Co-authored-by: OmX <omx@oh-my-codex.dev>

* Enforce fail-closed candidate identity parsing

Constraint: PR #266 review required exact candidate-name matching and CLI help regression coverage.\nRejected: fallback-to-query-name on missing upstream markup | it can mislabel unrelated candidates as exact matches.\nConfidence: high\nScope-risk: narrow\nDirective: Keep NEC parser changes fail-closed when candidate identity cannot be parsed.\nTested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live CLI smoke for 오세훈; CLI --help smoke.\nNot-tested: repo-wide npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal.

* Preserve unique candidate lookup results

Deduplicate parsed NEC candidate/election rows before applying user limits, and make expected CLI validation failures concise by default while keeping an explicit debug stack escape hatch.

Constraint: PR #266 round-2 follow-up requested TDD fixes for duplicate NEC rows and CLI validation UX.\nRejected: Deduplicating after limit | would still allow duplicates to crowd out unique rows.\nRejected: Always printing stack traces | exposes local paths for normal user-input failures.\nConfidence: high\nScope-risk: narrow\nDirective: Keep dedupe keys stable enough to avoid collapsing legitimately distinct historical election rows.\nTested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live 오세훈 smoke; live 김동연 duplicate repro; CLI no-args/help.\nNot-tested: Full npm run ci remains blocked by pre-existing missing SKILL.md: ohou-today-deal.

* Prevent filtered NEC lookup false negatives

Fix the candidate parser so documented education-superintendent and filtered local-election lookups return bounded, evidence-backed results instead of silently dropping valid rows.

Constraint: PR #266 round-3 review required TDD, Ralph verification, and branch update for issue #256.

Rejected: Full NEC pagination in this follow-up | broader than the approved change; bounded 100-row fetch now avoids user-limit false negatives and warns when capped.

Confidence: high

Scope-risk: narrow

Directive: Preserve exact-name fail-closed parsing and count raw parsed upstream rows before cap-warning decisions.

Tested: git diff --check; node --test packages/local-election-candidate-search/test/index.test.js; npm run lint --workspace local-election-candidate-search; npm run test --workspace local-election-candidate-search; npm pack --workspace local-election-candidate-search --dry-run; live CLI smokes for 오세훈, 조희연, 김동연; CLI help/no-args checks; architect verification CLEAR.

Not-tested: Full npm run ci remains blocked by pre-existing repo-wide missing SKILL.md: ohou-today-deal.

---------

Co-authored-by: OmX <omx@oh-my-codex.dev>

* chore(changesets): rename daiso bearer-auth changeset to avoid name collision with consumed main release

PR #245 already consumed .changeset/issue-207-daiso-pickup-eligibility.md
into daiso-product-search v0.3.0 on main. The dev branch later modified that
same changeset file in d7263a5 to describe the newer Bearer-auth fix, which
collides with main's deletion on the next dev→main sync.

Renaming the still-unreleased Bearer-auth note to
issue-207-daiso-bearer-auth.md preserves the release entry for the next
version-packages run and clears the modify/delete conflict on PR #271
without losing the changelog content.

* fix(kstartup-search): implement promised client-side filter to deliver on SKILL.md L121

Live data revealed two unmet contracts in the kstartup-search helper:

1. SKILL.md L121 promised the helper re-applies supt_regin / aply_trgt /
   biz_enyy filters on the client side because K-Startup upstream ignores
   them server-side. The helper had no such logic — calling
   `--supt-regin 서울특별시 --rcrt-prgs-yn Y` returned 경북/충북/충남
   announcements as-is, silently misleading callers.

2. The upstream `supt_regin` field is stored as the short form
   (`서울`, `경기`, `충북`, ...) but every CLI example in the skill used
   the standard 광역지자체 long form (`서울특별시`), which would never
   substring-match even after a client filter was added.

Add `apply_client_filters()` that runs after `urlopen` returns. It honors
the SKILL.md contract literally: substring match per token, AND-joined
across comma-separated user values, with a 17-region (+`전국`) shortname
normalisation table so both `--supt-regin 서울특별시` and
`--supt-regin 서울` resolve to upstream's `서울`. Filtered responses
expose a new `client_filter: {fields, upstream_returned, after_filter}`
metadata block so callers can detect "first page was depleted by filter"
and page through.

Tests: 9 new ClientFilterTests + 2 normalisation tests on top of the
existing 14 (25 total, all passing).

Live smoke (against a dev proxy with DATA_GO_KR_API_KEY activated for
dataset 15125364): `--supt-regin 서울특별시 --rcrt-prgs-yn Y --per-page 10`
now returns 4 actual 서울 announcements (upstream returned 10 mixed-region
rows; client filter narrowed to 4), with detl_pg_url to k-startup.go.kr.

Confidence: high. Scope-risk: narrow — purely additive on the response
path; other endpoints (business-info / contents / statistics) pass
through unchanged.

* ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy via GitHub Actions

main에 머지되면 GitHub Actions가 자동으로 Workload Identity Federation으로 GCP 인증 후
Artifact Registry에 컨테이너 이미지를 빌드/푸시하고 Cloud Run(asia-northeast1) 서비스
k-skill-proxy를 재배포한다. 시크릿은 GCP Secret Manager에서 런타임에 주입된다.

- add .github/workflows/deploy-k-skill-proxy.yml (WIF, on push to main)
- add packages/k-skill-proxy/Dockerfile (multi-stage node:20-alpine, port bridge)
- add docs/deploy-k-skill-proxy.md (1회성 GCP 셋업 + 운영 점검 절차)
- remove ecosystem.config.cjs (PM2 root config)
- remove scripts/run-k-skill-proxy.sh (local secrets.env source + node launcher)
- remove wrangler devDependency (unused Cloudflare Workers CLI)
- update AGENTS.md, CLAUDE.md, CONTRIBUTING.md, docs/features/k-skill-proxy.md,
  packages/k-skill-proxy/README.md to describe the new Cloud Run + GHA flow
- clean dead k-skill-proxy-cloudrun entries from .gitignore

* docs(AGENTS): proxy 운영 전반(회전·롤백·비상 수동 배포 포함) docs/deploy-k-skill-proxy.md 참고 명시

* test(skill-docs): update stale CONTRIBUTING.md assertion for Cloud Run migration

80e7805(ci(k-skill-proxy): replace local pm2+cloudflared with Cloud Run auto-deploy)
가 CONTRIBUTING.md의 '프록시 서버 개발과 배포' 섹션을 Cloud Run + GCP Secret
Manager 흐름으로 다시 썼는데, 같은 섹션을 검증하는 skill-docs.test.js의 어서션은
구버전(`~/.local/share/k-skill-proxy`) 그대로였다. PR #276 CI에서 이 stale
어서션이 fail하여 머지를 막고 있었다.

기존 한 줄 regex(localhost 시크릿 경로)를 새 사실에 맞춰 두 개의 어서션으로 교체:

1. 프로덕션이 Google Cloud Run(asia-northeast1) + k-skill-proxy.nomadamas.org에서
   운영된다는 문구를 강제한다.
2. 시크릿이 GCP Secret Manager에 있고 운영 점검 절차가
   docs/deploy-k-skill-proxy.md에 있다는 문구를 강제한다.

이렇게 하면 문서가 다시 옛 로컬 흐름으로 돌아가거나 운영 가이드 링크가 빠지는
회귀가 발생할 때 CI가 잡아준다.

---------

Co-authored-by: arnold714 <arnold714@naver.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: chanmin <cmju@cowave.kr>
Co-authored-by: OmX <omx@oh-my-codex.dev>
Co-authored-by: hmmhmmhm/ <hmmhmmhm@naver.com>
Co-authored-by: 배기민 <53887180+BAEM1N@users.noreply.github.com>
Co-authored-by: lee-ji-hong <zhffktkdlekghksxk@naver.com>
2026-05-22 11:40:40 +09:00
132 changed files with 12384 additions and 2389 deletions

View file

@ -1,5 +0,0 @@
---
"daiso-product-search": minor
---
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.

View file

@ -1,5 +0,0 @@
---
"emergency-room-beds": minor
---
Add an E-Gen based nearby emergency-room status skill and package.

View file

@ -1,5 +0,0 @@
---
"k-skill-proxy": minor
---
Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).

View file

@ -1,5 +0,0 @@
---
"local-election-candidate-search": minor
---
Add a public NEC local election candidate lookup skill and helper CLI.

View file

@ -1,5 +0,0 @@
---
"k-skill-proxy": minor
---
Add Seoul Bike realtime, station master, and nearby lookup proxy routes.

View file

@ -1,5 +0,0 @@
---
"sh-notice-search": minor
---
Add a policy-compliant SH public notice search skill and direct HTML lookup client.

View file

@ -0,0 +1,13 @@
{
"name": "k-skill",
"owner": {
"name": "NomaDamas"
},
"plugins": [
{
"name": "k-skill",
"source": "./",
"description": "한국인을 위한 90+ Agent Skill 번들 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화"
}
]
}

110
.claude-plugin/plugin.json Normal file
View file

@ -0,0 +1,110 @@
{
"name": "k-skill",
"description": "한국인을 위한 90+ Agent Skill 모음 — SRT/KTX/당근/쿠팡/카톡/정부24 등 한국 일상·업무 자동화",
"version": "1.0.0",
"author": {
"name": "NomaDamas"
},
"homepage": "https://github.com/NomaDamas/k-skill",
"repository": "https://github.com/NomaDamas/k-skill",
"license": "MIT",
"skills": [
"./biz-health-check",
"./bunjang-search",
"./catchtable-sniper",
"./cheap-gas-nearby",
"./corporate-registration-consulting",
"./coupang-product-search",
"./court-auction-notice-search",
"./daangn-cars-search",
"./daangn-jobs-search",
"./daangn-realty-search",
"./daangn-used-goods-search",
"./daishin-report-search",
"./daiso-product-search",
"./danawa-price-search",
"./delivery-tracking",
"./donation-place-search",
"./emergency-room-beds",
"./express-bus-booking",
"./fine-dust-location",
"./flight-ticket-search",
"./foresttrip-vacancy",
"./fsc-corporate-info",
"./g2b-sanctioned-supplier",
"./gangnamunni-clinic-search",
"./geeknews-search",
"./gongsijiga-search",
"./han-river-water-level",
"./hipass-receipt",
"./hola-poke-yeoksam",
"./household-waste-info",
"./hwp",
"./intercity-bus-booking",
"./iros-registry-automation",
"./jobkorea-talent-search",
"./joseon-sillok-search",
"./k-dart",
"./k-schoollunch-menu",
"./k-skill-cleaner",
"./k-skill-setup",
"./kakao-bar-nearby",
"./kakao-map",
"./kakaotalk-mac",
"./kbl-results",
"./kbo-results",
"./kleague-results",
"./korea-weather",
"./korean-character-count",
"./korean-cinema-search",
"./korean-humanizer",
"./korean-jangbu-for",
"./korean-law-search",
"./korean-marathon-schedule",
"./korean-middle-korean",
"./korean-patent-search",
"./korean-privacy-terms",
"./korean-scholarship-search",
"./korean-slang-writing",
"./korean-spell-check",
"./korean-stock-search",
"./korean-transit-route",
"./kosis-stats",
"./kstartup-search",
"./ktx-booking",
"./lck-analytics",
"./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",
"./public-restroom-nearby",
"./real-estate-search",
"./rhwp-advanced",
"./rhwp-edit",
"./saramin-talent-search",
"./seoul-bike",
"./seoul-density",
"./seoul-subway-arrival",
"./sh-notice-search",
"./srt-booking",
"./subway-lost-property",
"./ticket-availability",
"./toss-securities",
"./used-car-price-search",
"./zipcode-search"
]
}

View file

@ -79,7 +79,6 @@ jobs:
SEOUL_OPEN_API_KEY=SEOUL_OPEN_API_KEY:latest
HRFCO_OPEN_API_KEY=HRFCO_OPEN_API_KEY:latest
OPINET_API_KEY=OPINET_API_KEY:latest
BLUE_RIBBON_SESSION_ID=BLUE_RIBBON_SESSION_ID:latest
DATA_GO_KR_API_KEY=DATA_GO_KR_API_KEY:latest
KEDU_INFO_KEY=KEDU_INFO_KEY:latest
DATA4LIBRARY_AUTH_KEY=DATA4LIBRARY_AUTH_KEY:latest
@ -89,6 +88,7 @@ jobs:
KOSIS_API_KEY=KOSIS_API_KEY:latest
NAVER_SEARCH_CLIENT_ID=NAVER_SEARCH_CLIENT_ID:latest
NAVER_SEARCH_CLIENT_SECRET=NAVER_SEARCH_CLIENT_SECRET:latest
LAW_OC=LAW_OC:latest
env_vars: |-
KSKILL_PROXY_HOST=0.0.0.0
KSKILL_PROXY_NAME=k-skill-proxy

View file

@ -37,6 +37,41 @@ jobs:
- run: npm ci
- run: npm run ci
- name: Preflight verify npm auth
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
echo "::group::npm whoami"
NPM_USER=$(npm whoami 2>&1) || {
echo "::error::npm whoami failed NPM_TOKEN is invalid or expired. Rotate the token and update the repository secret."
exit 1
}
echo "Authenticated as: ${NPM_USER}"
echo "::endgroup::"
- name: Preflight list unpublished packages (diagnostic)
run: |
set -euo pipefail
echo "Packages that will be published:"
FOUND=0
for pkg_json in packages/*/package.json; do
PRIVATE=$(node -e "console.log(require('./${pkg_json}').private || false)")
[ "$PRIVATE" = "true" ] && continue
PKG=$(node -e "console.log(require('./${pkg_json}').name)")
LOCAL_VER=$(node -e "console.log(require('./${pkg_json}').version)")
REMOTE_VER=$(npm view "${PKG}" version 2>/dev/null || echo "")
if [ "${LOCAL_VER}" != "${REMOTE_VER}" ]; then
echo " → ${PKG}@${LOCAL_VER} (npm: ${REMOTE_VER:-not yet published})"
FOUND=1
fi
done
if [ "$FOUND" -eq 0 ]; then
echo " (none all versions are already on npm)"
fi
- name: Create npm release PR or publish changed packages
uses: changesets/action@v1
with:

2
.gitignore vendored
View file

@ -9,5 +9,7 @@ node_modules/
__pycache__/
dist/
.sisyphus/
.omo/
.gjc/
.agents/

View file

@ -23,15 +23,16 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 할 수 있는 일 | 스킬 이름 | 설명 | 사용자 로그인 | 문서 |
| --- | --- | --- | --- | --- |
| SRT 예매 | `srt-booking` | SRT 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| KTX 예매 | `ktx-booking` | KTX/Korail 열차 조회, 호차별 좌석번호·콘센트 좌석 확인, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 고속버스 예매 | `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) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
| 카카오맵 장소·자동차 길찾기 | `kakao-map` | Kakao Local 키워드/카테고리/좌표↔주소 변환 + Kakao Mobility 자동차 길찾기(거리·소요시간·통행료·예상 택시요금) | 불필요 | [카카오맵 가이드](docs/features/kakao-map.md) |
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
@ -41,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) |
@ -59,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) |
@ -72,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) |
@ -80,10 +89,9 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| HWP 문서 조회/변환 | `hwp` | `.hwp/.hwpx` → Markdown/JSON 변환, 문서 비교, 양식 필드 추출, Markdown→HWPX 역변환 (kordoc 기반 read-only) | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| HWP 문서 편집 | `rhwp-edit` | `.hwp` 본문 텍스트 삽입/삭제, 표 생성, 셀 수정, replace-all (`k-skill-rhwp` CLI + `@rhwp/core` WASM, HWP 5.x round-trip) | 불필요 | [HWP 문서 편집 가이드](docs/features/rhwp-edit.md) |
| HWP 레이아웃·IR 디버깅 | `rhwp-advanced` | 업스트림 `rhwp` Rust CLI(`export-svg --debug-overlay`, `dump`, `dump-pages`, `ir-diff`, `thumbnail`, `convert`)로 HWP 레이아웃 진단·IR 덤프·버전 비교·썸네일 추출·배포용 문서 잠금 해제 | 불필요 | [HWP 레이아웃·IR 디버깅 가이드](docs/features/rhwp-advanced.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~`blue-ribbon-nearby`~~ | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | `kakao-bar-nearby` | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | `zipcode-search` | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 다이소 상품 조회 | `daiso-product-search` | 다이소 매장별 상품 픽업 가능 여부 확인 (정확한 매장별 재고 수량은 다이소몰 보안 정책으로 2026-05-05 부터 차단됨) | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 강남언니 병원 조회 | `gangnamunni-clinic-search` | 강남언니 공개 검색 페이지에서 성형외과·피부과 병원 후보, 평점, 리뷰 수, 지원 언어, 공개 링크 조회 | 불필요 | [강남언니 병원 조회 가이드](docs/features/gangnamunni-clinic-search.md) |
| 마켓컬리 상품 조회 | `market-kurly-search` | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | `olive-young-search` | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
@ -107,16 +115,20 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
| 한국어 AI 윤문 | `korean-humanizer` | AI가 쓴 티 나는 한국어 글을 번역체·AI 상투어·과장된 의의·줄표/이모지 등 흔적을 심각도(S1/S2/S3)로 분류해 의미는 보존하며 사람 글로 윤문, 목표 글자수도 맞춤 | 불필요 | [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md) |
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
>
> **블루리본 측이 `www.bluer.co.kr` 에 자동화 접근 전면 차단을 적용해 스킬이 더 이상 동작하지 않습니다.**
>
> - 브라우저·`curl`·Playwright·TLS impersonation 등 가능한 우회를 모두 검증했지만 nginx 단에서 403이 반환되며, 같은 가구 공인 IP로도 특정 장비만 차단되는 상황이 관측되었습니다.
> - 유료 회원권 보유자도 접근이 막히는 사례가 확인되었습니다. 복구 여부와 일정은 블루리본 측 정책에 전적으로 달려 있어 이 레포에서 대응할 수 있는 범위를 벗어났습니다.
> - 해당 스킬 디렉토리(`blue-ribbon-nearby/`)와 관련 프록시 라우트는 히스토리 보존을 위해 당분간 남겨두지만, **새 프로젝트에서는 해당 스킬을 사용하지 마세요.** 차단이 해제되는 날이 오면 이 안내를 제거하고 재검증하겠습니다.
## Claude Code 플러그인으로 설치
[Claude Code](https://claude.com/claude-code)에서는 마켓플레이스로 전체 스킬을 한 번에 설치할 수 있습니다.
```
/plugin marketplace add NomaDamas/k-skill
/plugin install k-skill@k-skill
```
설치하면 스킬이 `/k-skill:<스킬 이름>` 네임스페이스로 호출됩니다 (예: `/k-skill:lotto-results`). 개별 디렉토리를 직접 복사하는 수동 설치나 다른 에이전트 설치는 [설치 방법](docs/install.md)을 참고하세요.
## 처음 시작하는 순서
@ -147,10 +159,11 @@ 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)
- [카카오맵 가이드](docs/features/kakao-map.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
@ -159,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)
@ -198,7 +217,6 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [HWP 문서 조회/변환](docs/features/hwp.md)
- [HWP 문서 편집](docs/features/rhwp-edit.md)
- [HWP 레이아웃·IR 디버깅](docs/features/rhwp-advanced.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
@ -225,6 +243,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
- [한국어 AI 윤문 가이드](docs/features/korean-humanizer.md)
- [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md)
- [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md)
- [릴리스/배포 가이드](docs/releasing.md)

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

@ -13,7 +13,7 @@
| 유형 | 설명 | 예시 |
|------|------|------|
| **SKILL.md 전용** | 문서만으로 동작 (에이전트가 bash/python 직접 실행) | `kakaotalk-mac`, `srt-booking` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `blue-ribbon-nearby` |
| **npm 패키지** | `packages/` 아래 Node.js 라이브러리로 구현 | `k-lotto`, `daiso-product-search` |
| **프록시 경유** | `k-skill-proxy`가 upstream API 키를 보관하고 HTTP로 중계 | `seoul-subway-arrival`, `fine-dust-location` |
| **Python 스크립트** | `scripts/`의 Python 파일 직접 실행 | `korean-spell-check`, `sillok-search` |

View file

@ -117,9 +117,9 @@ gcloud projects add-iam-policy-binding "$PROJECT_ID" \
RUNTIME_SA="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com"
for s in \
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_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}" \
@ -157,9 +157,9 @@ gcloud iam workload-identity-pools providers describe "$PROVIDER_ID" \
```bash
KEYS=(
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
OPINET_API_KEY BLUE_RIBBON_SESSION_ID DATA_GO_KR_API_KEY KEDU_INFO_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

@ -80,6 +80,7 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --forest-name 유
3. helper로 read-only 월별예약조회 endpoint를 실행한다.
4. helper가 로그인 세션, CSRF, 공식 휴양림 ID 목록을 확보한다.
5. 날짜, 휴양림명, 객실/시설명, 숙박/야영 구분, 정원 중심으로 요약한다.
6. 응답 정제: API가 `srchDate` 기준 최대 5일 윈도우를 반환할 수 있어 helper가 요청 범위 밖 `useDt`, 운영자 보유분("예비" 포함 객실), 같은 객실 중복 행을 자동 제거한다.
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 구현은 로그인 세션/CSRF 확보를 필수 전제로 둔다.
@ -136,6 +137,8 @@ python3 foresttrip-vacancy/scripts/run_foresttrip_vacancy.py --all --text --date
- aggressive polling은 피한다.
- 조회 결과는 시점 차이로 숲나들e 화면과 달라질 수 있다.
- 로그인 실패 시 계정 정보 또는 숲나들e 정책 변경을 먼저 확인한다.
- API가 요청 날짜보다 넓은 5일 윈도우를 반환해도 출력에는 요청 범위(`today``last_day`) 안의 행만 포함된다.
- "예비" 표기가 있는 객실은 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 결과에서 자동 제외된다.
## 흔한 문제 해결

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

@ -27,7 +27,7 @@ python3 scripts/k_skill_cleaner.py \
--skills-root . \
--scan-default-logs \
--days 90 \
--never-use blue-ribbon-nearby,lotto-results \
--never-use lotto-results \
--keep k-skill-setup,k-skill-cleaner
```

104
docs/features/kakao-map.md Normal file
View file

@ -0,0 +1,104 @@
# 카카오맵 가이드
## 이 기능으로 할 수 있는 일
- **장소 검색**: 키워드(`스타벅스`)·카테고리(`FD6`=음식점)·좌표 중심으로 가게·시설 검색 (Kakao Local API)
- **좌표 ↔ 주소 변환**: 좌표 → 도로명/지번 주소, 좌표 → 행정구역(법정동/행정동)
- **자동차 길찾기**: 출발지·목적지 좌표 기준 거리·소요시간·통행료·예상 택시 요금 (Kakao Mobility Directions)
- 모두 `k-skill-proxy` 경유. 사용자 키 발급 불필요.
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- 사용자는 별도 Kakao Developers 앱 생성/키 발급 필요 없음
- 운영자(proxy 서버)는 `KAKAO_REST_API_KEY` 보유
## 기본 경로
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/kakao-map/*`, `https://k-skill-proxy.nomadamas.org/v1/kakao-mobility/*`
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/kakao-map/search/keyword` | `https://dapi.kakao.com/v2/local/search/keyword.json` | `q`, `x`, `y`, `radius`, `category_group_code`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/search/category` | `https://dapi.kakao.com/v2/local/search/category.json` | `category_group_code`, `x`, `y`, `radius`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | `https://dapi.kakao.com/v2/local/geo/coord2address.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-map/coord2region` | `https://dapi.kakao.com/v2/local/geo/coord2regioncode.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-mobility/directions` | `https://apis-navi.kakaomobility.com/v1/directions` | `origin=x,y`, `destination=x,y`, `waypoints`, `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`, `car_hipass`, `alternatives`, `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
## 기본 흐름
1. 사용자가 장소 키워드/카테고리/좌표/길찾기 질문을 한다.
2. 적합한 endpoint를 골라 proxy 로 호출한다 (위 표 참고).
3. proxy는 `KAKAO_REST_API_KEY` 를 서버측에서만 `Authorization: KakaoAK ...` 헤더로 주입한다.
4. 응답에서 핵심 필드만 추려 사용자에게 정리해 전달한다.
5. 성공 응답은 proxy cache(기본 TTL 5분)로 보관해 다음 동일 쿼리를 빠르게 돌려준다.
## 예시
키워드 검색:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/kakao-map/search/keyword" \
--data-urlencode 'q=스타벅스' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=500' \
--data-urlencode 'sort=distance'
```
좌표 → 주소:
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
자동차 길찾기:
```bash
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답 요약(예):
```text
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND, avoid=toll
```
## fallback / 대체 흐름
- 키 누락(`503 upstream_not_configured`) → 사용자에게 운영자 설정 필요 안내
- 인증 실패(401/403) → `503` 으로 변환 (key revoke / 쿼터 초과)
- 좌표 형식 오류 / 미존재 카테고리 코드 → `400 bad_request`
- 경로 미발견·출발지=도착지 근접 등 semantic 실패 → `502 upstream_semantic_error` + `result_msg`
- 네트워크 실패 → `502 upstream_error`
## 주의할 점
- Kakao Mobility는 **자동차 전용**이다. 대중교통 길찾기는 [한국 대중교통 길찾기 가이드](korean-transit-route.md) 를 쓴다.
- 카테고리 검색은 좌표 중심(`x`, `y`)이 필수다.
- waypoints 는 최대 5개 (Kakao Mobility 정책).
- 통행료 회피는 `avoid=toll`을 사용한다. `priority=DISTANCE`는 최단거리 우선순위일 뿐 통행료 회피와 동의어가 아니다.
- Kakao Mobility 무료 일일 쿼터는 1,000건 수준이다. proxy cache + rate-limit이 보호 역할을 하지만, 대량 호출은 자제한다.
- 본 스킬은 데이터 조회 전용이다. 예약·결제·자동 운전은 하지 않는다.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버측에서만 주입).
## 참고 표면
- Kakao Developers Console: `https://developers.kakao.com`
- Kakao Local API 문서: `https://developers.kakao.com/docs/latest/ko/local/dev-guide`
- Kakao Mobility 안내: `https://developers.kakao.com/docs/latest/ko/kakaonavi/common`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

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

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

View file

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

View file

@ -4,6 +4,8 @@
- KTX/Korail 열차 조회
- 좌석 가능 여부 확인
- 호차별 남은 좌석번호 확인
- 콘센트 꿀팁 좌석 필터링
- 예약 진행
- 예약 내역 확인
- 예약 취소
@ -35,6 +37,7 @@
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 좌석 우선
- 조회 결과에서 복사한 `train_id`
## 왜 helper 를 쓰는가
@ -54,8 +57,9 @@
2. `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` 가 없으면 credential resolution order에 따라 확보한다.
3. helper 로 먼저 열차를 조회한다.
4. 후보 열차의 `index`, `train_id`, 출발/도착 시각, KTX 여부, 좌석 여부를 보여준다.
5. 대상 열차가 명확할 때만 예약한다.
6. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
5. 사용자가 좌석번호, 호차별 잔여석, 콘센트 꿀팁 좌석을 물으면 `seats` 로 상세 좌석을 먼저 확인한다.
6. 대상 열차가 명확할 때만 예약한다.
7. 예약 확인/취소는 대상 예약을 다시 식별한 뒤 진행한다.
## 예시
@ -69,6 +73,43 @@ python3 scripts/ktx_booking.py search 서울 부산 20260328 090000 --limit 5
응답 JSON 의 `train_id` 는 검색 시점의 정확한 열차를 가리키는 stable selector 다. 예약할 때는 이 값을 그대로 복사해서 쓴다. 같은 열차가 더 이상 조회되지 않으면 helper 가 실패하고 새로 조회하게 만든다.
상세 좌석 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
```
남은 좌석번호만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안에서는 콘센트 힌트가 있는 좌석을 먼저, 같은 조건에서는 순방향 좌석을 먼저 반환한다.
특정 호차의 남은 좌석만 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 좌석부터 확인:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
특실 좌석을 확인하려면 `--room special`, KTX 외 열차를 조회했다면 `search` 와 같은 `--train-type` 을 함께 넘긴다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--train-id <train_id> \
--train-type itx-cheongchun \
--available-only
```
`seats` 응답은 호차별 `remaining_seats`, `available_seats`, 좌석별 순방향/역방향, 창측/내측, 좌석 종류, 문 근처 여부, 콘센트 힌트를 JSON 으로 반환한다. 이 단계는 좌석을 선택하거나 선점하지 않고, 예약 전 확인만 한다.
예약:
```bash

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

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

View file

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

View file

@ -86,7 +86,6 @@ npx --yes skills add <owner/repo> \
--skill olive-young-search \
--skill korean-cinema-search \
--skill hola-poke-yeoksam \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking \
@ -130,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)를 본다.
@ -332,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

@ -32,7 +32,6 @@
- 근처 가장 싼 주유소 찾기 스킬 출시
- 근처 공중화장실 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시

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

@ -8,7 +8,8 @@
- `korail2` anti-bot bypass PR #54: https://github.com/carpedm20/korail2/pull/54
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy``/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct``/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다.
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy``/v1/kakao-local/geocode``/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다. 같은 host의 `/search/keyword.json`, `/search/category.json`, `/geo/coord2address.json`, `/geo/coord2regioncode.json``kakao-map` 스킬용 `/v1/kakao-map/*` 라우트가 직접 중계한다.
- Kakao Mobility Directions endpoint: https://apis-navi.kakaomobility.com/v1/directions — `k-skill-proxy``/v1/kakao-mobility/directions`가 운영자 `KAKAO_REST_API_KEY``Authorization: KakaoAK ...` 헤더로 주입해 자동차 길찾기를 중계한다.
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do
@ -17,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
@ -89,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/
@ -154,9 +158,6 @@
- 당근알바 검색 Remix data route: https://www.daangn.com/kr/jobs/?_data=routes/kr.jobs._index
- 당근중고차 검색 Remix data route: https://www.daangn.com/kr/cars/?_data=routes/kr.cars._index
- 당근부동산 상세 페이지: https://realty.daangn.com/articles/<id>
- 블루리본 메인: https://www.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
- 조선왕조실록 메인: https://sillok.history.go.kr
@ -208,6 +209,28 @@
- 도서관 정보나루 도서 소장 도서관 endpoint: https://data4library.kr/api/libSrchByBook
- 도서관 정보나루 도서관별 도서 소장여부 endpoint: https://data4library.kr/api/bookExist
- 공공데이터포털 데이터셋(창업진흥원 K-Startup 조회서비스): https://www.data.go.kr/data/15125364/openapi.do
- K-Startup Open API base URL: https://apis.data.go.kr/B552735/kisedKstartupService01 — `k-skill-proxy``/v1/kstartup/business-info`, `/v1/kstartup/announcements`, `/v1/kstartup/contents`, `/v1/kstartup/statistics` 가 각각 `getBusinessInformation01`, `getAnnouncementInformation01`, `getContentInformation01`, `getStatisticalInformation01` 로 중계한다 (returnType=json 고정, ServiceKey 서버 측 주입)
- K-Startup 공식 포털: https://www.k-startup.go.kr — 응답의 `detl_pg_url` 가 가리키는 사용자 진입점
### 지자체/유관기관 참고 사이트 (보조 소스)
- **서울시 창업플러스**: https://seoulstartup.go.kr
- **경기도 창업진흥원**: https://g-startup.kr
- **부산시 스타트업 허브**: https://busanstartup.kr
- **광주창업파크**: https://startup.gwangju.kr
- **대구창업진흥원**: https://daegu-startup.kr
- **중소기업진흥공단**: https://smbs.or.kr
- **기술보증기금**: 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

@ -100,6 +100,8 @@ python3 -m playwright install chromium
2026-04-29 확인 기준, 로그인 없이 월별예약조회 화면에 접근하면 `401 Unauthorized`가 반환되고, 조회 endpoint는 JSON 대신 안내 HTML을 반환한다. 따라서 현재 helper는 로그인 세션/CSRF 확보를 필수 전제로 둔다.
API는 `srchDate` 단일 일자만 요청해도 응답에 5일 윈도우를 포함할 수 있다. helper는 요청 범위(`today``last_day`) 밖 `useDt` 행을 자동 제거하므로 사용자에게는 요청한 날짜의 빈자리만 노출된다.
전체 자연휴양림에서 특정 날짜 조회:
```bash
@ -138,6 +140,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
결과가 없으면 "조회 시점 기준 예약 가능 객실 없음"이라고 말한다. 실제 예약 가능 여부는 숲나들e 화면에서 재확인될 수 있음을 덧붙인다.
`goodsNm`에 "예비"가 포함된 객실은 운영자가 보유하는 내부용 자리로, 사용자 예약 화면에는 노출되지 않는다. helper는 이 객실들을 결과에서 자동 제외한다. 같은 `(휴양림, 날짜, 객실명)` 조합의 중복 행도 dedup된다.
## Done when
- 요청 날짜와 조회 범위가 명확하다.
@ -152,6 +156,8 @@ python3 scripts/run_foresttrip_vacancy.py --forest-name 유명산 --text --dates
- Playwright browser 미설치: `python3 -m playwright install chromium`
- fetch failure 일부 발생: 결과와 실패 개수를 함께 보고하고, 필요하면 `--refresh-session` 으로 1회 재조회
- 숲나들e 표면 변경: helper의 login/session bootstrap 또는 parser 점검 필요
- "(예비)" 객실이 결과에 안 나옴: 정상 동작이다. 사용자 예약 화면에 노출되지 않는 운영자 보유분이라 의도적으로 제외된다.
- 사용자 화면 객실 수와 helper 결과가 다름: 같은 객실의 중복 행이 dedup되었거나, 요청 범위 밖 `useDt`가 제거됐을 가능성이 높다. raw API 응답을 확인하려면 helper 로직을 우회해서 직접 호출 필요.
## Maintainer review notes

View file

@ -30,6 +30,7 @@ DEFAULT_CONCURRENCY = 4
MAX_CONCURRENCY = 5
DEFAULT_WEEK_RANGE = 1
CATEGORY_CODES = {"01", "02"}
RESERVE_ROOM_MARKER = "예비"
@dataclass
@ -159,8 +160,8 @@ def check_dependencies(*, launch_browser: bool = True) -> None:
if sys.version_info < (3, 9):
raise SystemExit("python 3.9+ is required")
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright"
@ -209,8 +210,8 @@ def save_session_cache(path: Path, session: Session) -> None:
def bootstrap_session(*, forest_id: str, forest_pw: str, ttl_sec: int = 600) -> Session:
try:
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from playwright.sync_api import Error as PlaywrightError # type: ignore[reportMissingImports]
from playwright.sync_api import sync_playwright # type: ignore[reportMissingImports]
except ImportError as exc:
raise SystemExit(
"playwright is required. Install with: python3 -m pip install playwright "
@ -379,7 +380,18 @@ def fetch_one(
def is_available(row: dict[str, Any]) -> bool:
return row.get("rsrvtAvail") == "Y" and row.get("rsrvtCnt") == 0
count_value = row.get("rsrvtCnt")
if count_value is None:
return False
try:
reserved_count = int(count_value)
except ValueError:
return False
return row.get("rsrvtAvail") == "Y" and reserved_count == 0
def is_reserve_room(row: dict[str, Any]) -> bool:
return RESERVE_ROOM_MARKER in (row.get("goodsNm") or "")
def normalize_row(row: dict[str, Any], forests: dict[str, str]) -> dict[str, Any]:
@ -443,11 +455,27 @@ def collect_results(
for row in data:
if not is_available(row):
continue
if is_reserve_room(row):
continue
use_dt = row.get("useDt") or ""
if use_dt < today or use_dt > last_day:
continue
normalized = normalize_row(row, session.forests)
normalized["source_category"] = category
if date_filter is not None and normalized["use_dt"] not in date_filter:
continue
rows.append(normalized)
seen: set[tuple[str, str, str, str]] = set()
deduped: list[dict[str, Any]] = []
for row in rows:
key = (row["forest_id"], row["use_dt"], row["source_category"], row["name"])
if key in seen:
continue
seen.add(key)
deduped.append(row)
rows = deduped
grouped: dict[str, dict[str, list[dict[str, Any]]]] = {}
for row in sorted(rows, key=lambda item: (item["forest"], item["use_dt"], item["name"])):
grouped.setdefault(row["forest"], {}).setdefault(row["use_dt"], []).append(row)

View file

View file

@ -0,0 +1,167 @@
[
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "동백1",
"goodsId": "GID-A2",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "고로쇠1",
"goodsId": "GID-C",
"insttArea": "60㎡",
"mxmmAccptCnt": "10",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260513",
"dywkDtTpcd": "수",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260514",
"dywkDtTpcd": "목",
"goodsNm": "동백3",
"goodsId": "GID-D",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "동백1",
"goodsId": "GID-A1",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "해송2",
"goodsId": "GID-B",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 1
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "(예비) 201호",
"goodsId": "GID-RES1",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "중산막2",
"goodsId": "GID-E",
"insttArea": "32㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030059",
"insttNm": "거제자연휴양림",
"useDt": "20260517",
"dywkDtTpcd": "일",
"goodsNm": "동백3",
"goodsId": "GID-F",
"insttArea": "50㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,62 @@
[
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "206호 쑥부쟁이방",
"goodsId": "GID-G1",
"insttArea": "37㎡",
"mxmmAccptCnt": "8",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "201호 배꽃방(예비용)",
"goodsId": "GID-G2",
"insttArea": "30㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속휴양관",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "편백나무2호(예비용)",
"goodsId": "GID-G3",
"insttArea": "28㎡",
"mxmmAccptCnt": "6",
"goodsClsscNm": "숲속의집",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
},
{
"insttId": "ID02030072",
"insttNm": "구재봉자연휴양림",
"useDt": "20260516",
"dywkDtTpcd": "토",
"goodsNm": "은행나무방(예비용)",
"goodsId": "GID-G4",
"insttArea": "22㎡",
"mxmmAccptCnt": "4",
"goodsClsscNm": "트리하우스",
"insttAreaNm": null,
"wtngPssblYn": "Y",
"rsrvtAvail": "Y",
"rsrvtCnt": 0
}
]

View file

@ -0,0 +1,348 @@
import importlib.util
import io
import json
import sys
import unittest
from contextlib import redirect_stdout
from datetime import datetime
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "run_foresttrip_vacancy.py"
FIXTURES_DIR = SCRIPT_DIR / "fixtures"
def load_helper():
spec = importlib.util.spec_from_file_location("run_foresttrip_vacancy", HELPER_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load helper from {HELPER_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules["run_foresttrip_vacancy"] = module
spec.loader.exec_module(module)
return module
helper = load_helper()
def load_fixture(name):
return json.loads((FIXTURES_DIR / name).read_text(encoding="utf-8"))
GEOJE_ROWS = load_fixture("geoje_window.json")
GUJAEBONG_ROWS = load_fixture("gujaebong_window.json")
GEOJE_FOREST_ID = "ID02030059"
GEOJE_FOREST_NAME = "[공립](거제시)거제자연휴양림"
GUJAEBONG_FOREST_ID = "ID02030072"
GUJAEBONG_FOREST_NAME = "[공립](하동군)구재봉자연휴양림"
FIXED_NOW = datetime(2026, 5, 12, 0, 0, 0)
def make_session(forests):
return helper.Session(
cookies={},
csrf="dummy-csrf",
user_agent="test-ua",
forests=forests,
expires_at=FIXED_NOW.timestamp() + 3600,
)
def stub_fetch(rows):
def _stub(*, forest_id, category, **_):
matched = [r for r in rows if r.get("insttId") == forest_id]
return forest_id, category, matched, None
return _stub
def run_collect(session, targets, rows, *, dates=None, week_range=None, categories=("01",)):
with mock.patch.object(helper, "fetch_one", side_effect=stub_fetch(rows)):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
return helper.collect_results(
session=session,
targets=targets,
categories=categories,
dates=tuple(dates) if dates else None,
week_range=week_range,
concurrency=1,
)
class IsReserveRoomTest(unittest.TestCase):
def test_parens_with_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "201호 배꽃방(예비용)"}))
def test_parens_prefix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "(예비) 201호"}))
def test_predicate_with_simple_suffix(self):
self.assertTrue(helper.is_reserve_room({"goodsNm": "편백나무2호(예비용)"}))
def test_normal_room_passes(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": "동백1"}))
def test_empty_name(self):
self.assertFalse(helper.is_reserve_room({"goodsNm": ""}))
def test_missing_name_key(self):
self.assertFalse(helper.is_reserve_room({}))
class IsAvailableTest(unittest.TestCase):
def test_y_and_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 0}))
def test_y_and_string_zero_count(self):
self.assertTrue(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "0"}))
def test_y_but_already_booked(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": 1}))
def test_y_but_string_booked_count(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "Y", "rsrvtCnt": "1"}))
def test_not_available(self):
self.assertFalse(helper.is_available({"rsrvtAvail": "N", "rsrvtCnt": 0}))
class CollectResultsFilterTest(unittest.TestCase):
def setUp(self):
self.session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
self.targets = {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}
def test_geoje_5_13_three_unique_rooms_after_dedup_and_reserve_filter(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
self.assertEqual(payload["filter_hits"], 3)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"동백1", "해송2", "고로쇠1"})
def test_geoje_5_16_returns_zero_when_only_reserved_or_booked(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260516"])
self.assertEqual(payload["filter_hits"], 0)
self.assertEqual(payload["results"], [])
def test_geoje_5_17_two_rooms(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260517"])
self.assertEqual(payload["filter_hits"], 2)
names = {
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(names, {"중산막2", "동백3"})
def test_dates_outside_request_filtered_out(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
observed_dates = {
room["use_dt"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
}
self.assertEqual(observed_dates, {"20260513"})
def test_reserve_rooms_excluded_across_all_dates(self):
payload = run_collect(
self.session, self.targets, GEOJE_ROWS,
dates=["20260513", "20260516", "20260517"],
)
for forest in payload["results"]:
for date in forest["dates"]:
for room in date["rooms"]:
self.assertNotIn("예비", room["name"])
def test_dedup_collapses_duplicate_room_with_different_goods_id(self):
payload = run_collect(self.session, self.targets, GEOJE_ROWS, dates=["20260513"])
donbaek_count = sum(
1
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
if room["name"] == "동백1"
)
self.assertEqual(donbaek_count, 1)
def test_dedup_keeps_same_room_name_from_distinct_categories(self):
rows_by_category = {
"01": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "숙박",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
"02": [
{
"insttId": GEOJE_FOREST_ID,
"insttNm": GEOJE_FOREST_NAME,
"useDt": "20260513",
"goodsNm": "같은이름",
"goodsClsscNm": "야영",
"rsrvtAvail": "Y",
"rsrvtCnt": 0,
}
],
}
def fetch_category(*, forest_id, category, **_):
return forest_id, category, rows_by_category[category], None
with mock.patch.object(helper, "fetch_one", side_effect=fetch_category):
with mock.patch.object(helper, "datetime", wraps=datetime) as mock_dt:
mock_dt.now.return_value = FIXED_NOW
payload = helper.collect_results(
session=self.session,
targets=self.targets,
categories=("01", "02"),
dates=("20260513",),
week_range=None,
concurrency=1,
)
self.assertEqual(payload["filter_hits"], 2)
observed = [
(room["name"], room["category"])
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(observed, [("같은이름", "숙박"), ("같은이름", "야영")])
class StrictUseDtGateTest(unittest.TestCase):
"""Bug 1 regression: API returns 5-day window even when single-day requested."""
def test_useDt_before_today_blocked_even_if_available(self):
past_row = dict(GEOJE_ROWS[0])
past_row["useDt"] = "20260101"
rows = [past_row]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
def test_useDt_after_last_day_blocked(self):
far_future = dict(GEOJE_ROWS[0])
far_future["useDt"] = "20300101"
rows = [far_future]
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, rows,
week_range=1,
)
self.assertEqual(payload["filter_hits"], 0)
class PrintTextTest(unittest.TestCase):
"""print_text is the user-facing output path — guard against format regressions."""
def test_renders_forest_and_rooms(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260513"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
output = buffer.getvalue()
self.assertIn(GEOJE_FOREST_NAME, output)
self.assertIn("20260513", output)
self.assertIn("slot(s)", output)
self.assertIn("동백1", output)
def test_empty_results_message(self):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
payload = run_collect(
session, {GEOJE_FOREST_ID: GEOJE_FOREST_NAME}, GEOJE_ROWS,
dates=["20260516"],
)
buffer = io.StringIO()
with redirect_stdout(buffer):
helper.print_text(payload)
self.assertIn("(no available rooms at lookup time)", buffer.getvalue())
class MainOutputTest(unittest.TestCase):
def run_main(self, argv, payload):
session = make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME})
buffer = io.StringIO()
with mock.patch.object(sys, "argv", ["run_foresttrip_vacancy.py", *argv]):
with mock.patch.object(helper, "get_session", return_value=session):
with mock.patch.object(helper, "resolve_targets", return_value={GEOJE_FOREST_ID: GEOJE_FOREST_NAME}):
with mock.patch.object(helper, "collect_results", return_value=payload):
with redirect_stdout(buffer):
exit_code = helper.main()
return exit_code, buffer.getvalue()
def test_main_text_output_returns_success_when_fetches_succeed(self):
payload = run_collect(
make_session({GEOJE_FOREST_ID: GEOJE_FOREST_NAME}),
{GEOJE_FOREST_ID: GEOJE_FOREST_NAME},
GEOJE_ROWS,
dates=["20260513"],
)
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--text"], payload)
self.assertEqual(exit_code, 0)
self.assertIn("ForestTrip Vacancy Lookup", output)
self.assertIn("filter_hits: 3", output)
self.assertIn("동백1", output)
def test_main_json_output_reports_failure_and_returns_nonzero(self):
payload = {
"forests_scanned": 1,
"filter_hits": 0,
"fetch_failures": 1,
"failures": [{"forest_id": GEOJE_FOREST_ID, "category": "01", "error": "http_401"}],
"concurrency": 1,
"date_range": {"from": "20260512", "to": "20260513"},
"results": [],
}
exit_code, output = self.run_main(["--forest-id", GEOJE_FOREST_ID, "--json"], payload)
self.assertEqual(exit_code, 1)
rendered = json.loads(output)
self.assertEqual(rendered["fetch_failures"], 1)
self.assertEqual(rendered["failures"][0]["error"], "http_401")
class GroundTruthTest(unittest.TestCase):
"""Anchored to user-verified counts from foresttrip.go.kr on 2026-05-12.
Fixtures are simplified; tests assert the per-(forest, date) shape matches."""
def test_gujaebong_5_16_one_room_named_쑥부쟁이방(self):
session = make_session({GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME})
payload = run_collect(
session, {GUJAEBONG_FOREST_ID: GUJAEBONG_FOREST_NAME}, GUJAEBONG_ROWS,
dates=["20260516"],
)
self.assertEqual(payload["filter_hits"], 1)
names = [
room["name"]
for forest in payload["results"]
for date in forest["dates"]
for room in date["rooms"]
]
self.assertEqual(names, ["206호 쑥부쟁이방"])
if __name__ == "__main__":
unittest.main()

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

186
kakao-map/SKILL.md Normal file
View file

@ -0,0 +1,186 @@
---
name: kakao-map
description: Kakao Local (장소 검색·주소-좌표 변환) + Kakao Mobility (자동차 길찾기) 를 k-skill-proxy 경유로 조회한다. 사용자 키 불필요.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Kakao Map
## What this skill does
Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종류 질문에 답한다:
1. **장소 검색** — 키워드/카테고리/좌표 기준으로 가게·시설·랜드마크를 찾고, 좌표↔주소·행정구역을 변환한다 (Kakao Local REST API).
2. **자동차 길찾기** — 출발지·목적지 좌표를 받아 거리·소요시간·통행료·예상 택시 요금을 조회한다 (Kakao Mobility Directions API).
- 운영자 `KAKAO_REST_API_KEY` 를 proxy 서버에만 보관한다. 사용자는 키 발급 필요 없음.
- 두 API 모두 같은 Kakao REST API key (KakaoAK 헤더) 로 인증한다.
- 본 스킬은 **조회 전용**이다. 예약·결제·운전 자동화는 하지 않는다.
## When to use
- "강남역 근처 스타벅스 찾아줘" → keyword 검색 (x,y 중심)
- "역삼동 카페 카테고리로 보여줘" → category 검색 (FD6/CE7 등)
- "이 좌표가 어느 동/도로명 주소야?" → coord2address / coord2region
- "강남역에서 시청까지 자동차로 얼마나 걸려?" → Kakao Mobility directions
- "통행료 회피 경로로 알려줘" → avoid=toll (필요 시 priority=DISTANCE 병행)
## When NOT to use
- 대중교통(지하철·버스) 경로 → Kakao Mobility는 **자동차 전용**. 대중교통은 `korean-transit-route`(ODsay) 사용
- 도보·자전거 경로 (Kakao Mobility에 정식 API 없음)
- 실시간 교통 상황을 1분 단위로 추적 (proxy cache 가 있음)
- 카카오맵 외부 임베드/렌더링 (본 스킬은 데이터 조회만 함)
- 대량 인덱싱/스크래핑 (Kakao 약관 위반 + 일일 quota 초과 위험)
## Prerequisites
- Python 3 표준 라이브러리만 사용 가능. JS/curl 호출도 동일하게 지원.
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시 사용 시. 비우면 hosted `https://k-skill-proxy.nomadamas.org` 기본).
## Required environment variables
사용자 머신에는 **필요 없다.** 운영자 proxy 서버에 다음을 둔다:
- `KAKAO_REST_API_KEY` — Kakao Developers REST API 키 (Local + Mobility 공용)
키가 없으면 모든 `/v1/kakao-map/*``/v1/kakao-mobility/*` 라우트가 `503 upstream_not_configured` 를 돌려준다.
## Proxy routes
| endpoint | 용도 | 주요 입력 |
|---|---|---|
| `GET /v1/kakao-map/search/keyword` | 키워드 장소 검색 | `q`, optional `x`,`y`(중심좌표), `radius`(0~20000m), `category_group_code`, `sort`(accuracy\|distance), `page`(1~45), `size`(1~15) |
| `GET /v1/kakao-map/search/category` | 카테고리 장소 검색 (좌표 중심 필수) | `category_group_code`(예: FD6 음식점, CE7 카페), `x`, `y`, `radius`(기본 500), `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | 좌표 → 도로명/지번 주소 | `x`, `y`, optional `input_coord`(WGS84 기본) |
| `GET /v1/kakao-map/coord2region` | 좌표 → 행정구역(시/도/시군구/동) | `x`, `y`, optional `input_coord` |
| `GET /v1/kakao-mobility/directions` | 자동차 길찾기 | `origin=x,y`, `destination=x,y`, optional `waypoints`(최대 5, `\|` 구분), `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`(GASOLINE\|DIESEL\|LPG), `car_hipass`(true\|false), `alternatives`(true\|false), `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
**Kakao 카테고리 그룹 코드** (자주 쓰는 것):
| 코드 | 의미 |
|---|---|
| MT1 | 대형마트 |
| CS2 | 편의점 |
| PK6 | 주차장 |
| OL7 | 주유소/충전소 |
| SW8 | 지하철역 |
| BK9 | 은행 |
| CT1 | 문화시설 |
| AT4 | 관광명소 |
| AD5 | 숙박 |
| FD6 | 음식점 |
| CE7 | 카페 |
| HP8 | 병원 |
| PM9 | 약국 |
## Workflow
### 1. 키워드 검색
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/kakao-map/search/keyword" \
--data-urlencode 'q=스타벅스' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=500' \
--data-urlencode 'sort=distance'
```
응답의 `documents[]` 에서 `place_name`, `road_address_name`, `phone`, `place_url`, `distance` 를 추출해 사용자에게 보여준다.
### 2. 카테고리 검색
```bash
curl -fsS --get "${BASE}/v1/kakao-map/search/category" \
--data-urlencode 'category_group_code=FD6' \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979' \
--data-urlencode 'radius=300'
```
### 3. 좌표 → 주소
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
`documents[0].road_address.address_name`, `documents[0].address.address_name` 사용.
### 4. 좌표 → 행정구역
```bash
curl -fsS --get "${BASE}/v1/kakao-map/coord2region" \
--data-urlencode 'x=127.0276' \
--data-urlencode 'y=37.4979'
```
응답에 `region_type`(B=법정동, H=행정동) 별 결과가 들어있다.
### 5. 자동차 길찾기
```bash
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답에서 `routes[0].summary` 를 읽는다:
- `distance` (meter) → km 환산
- `duration` (second) → 분 환산
- `fare.taxi`, `fare.toll` → 원
- `priority` (요청한 값 echo)
- `avoid` 요청 시 `toll` 등 회피 옵션 적용
### 6. 출력 포맷
장소 검색:
```text
강남역 근처 스타벅스 5곳 (반경 500m, 가까운 순)
1) 스타벅스 강남R점 — 강남구 테헤란로 ... (120m, 02-...)
2) ...
```
자동차 길찾기:
```text
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND, avoid=toll
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## Failure modes
- `KAKAO_REST_API_KEY` 미설정 → `503 upstream_not_configured`
- Kakao 인증 실패(401/403) → proxy가 `503` 으로 변환 (key revoke / 쿼터 초과 신호)
- 좌표/파라미터 형식 오류 → `400 bad_request`
- 출발지=도착지가 너무 가까움 (`result_code=104` 등) → `502 upstream_semantic_error` + `result_msg`
- Kakao 일일 쿼터 초과 → `502` 또는 `503` (proxy cache 가 있는 만큼 호출 빈도를 줄임)
- 네트워크 실패 → `502 upstream_error`
## Done when
- 사용자 질문에 맞는 endpoint 1~2개를 선택해 호출했고, 응답을 사람-읽기 좋게 정리했다.
- 좌표나 주소는 출처 endpoint를 함께 명시한다 (Kakao Local vs Kakao Mobility).
- secret/token/.env 원문은 노출되지 않았다.
- 자동차 외 이동 수단을 요청받으면 본 스킬의 범위 외임을 명시하고 `korean-transit-route` 등 대체 안내.
## Notes
- Kakao Mobility는 **자동차 전용** API다. 대중교통 길찾기는 별도 ODsay 기반 `korean-transit-route` 스킬을 쓴다.
- 무료 일일 쿼터(2026년 기준 Local 300,000건 / Mobility 1,000건) 안에서 proxy cache(기본 TTL 5분) + rate-limit(기본 60req/분) 으로 보호한다.
- proxy 운영/환경변수는 [k-skill 프록시 서버 가이드](../docs/features/k-skill-proxy.md) 참고.
- `/v1/kakao-local/geocode` (기존)도 같은 키를 쓰며 여전히 사용 가능하다 (address → keyword 자동 fallback). 본 스킬은 그 위에 keyword/category/coord 계열을 명시적으로 노출한다.

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

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

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
---
name: ktx-booking
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, or reservation status.
description: Search, reserve, inspect, and cancel KTX or Korail tickets in Korea with the korail2 + pycryptodome Python packages. Use when the user asks for KTX seats, Korail bookings, train changes, reservation status, remaining seat numbers, car-by-car seats, or power-outlet/good-seat tips.
license: MIT
metadata:
category: travel
@ -12,7 +12,7 @@ metadata:
## What this skill does
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 예약, 예약 확인, 취소를 처리한다.
`korail2` 위에 `scripts/ktx_booking.py` helper 를 얹어 KTX/Korail 조회, 호차별 좌석번호 확인, 예약, 예약 확인, 취소를 처리한다.
최근 Korail 앱의 Dynapath anti-bot 체크 때문에 원본 `korail2` 0.4.0 예제만으로는 `MACRO ERROR` 가 날 수 있다. 이 스킬은 helper 가 `x-dynapath-m-token`, `Sid`, 최신 app version(`250601002`)을 붙여 실제 예매 흐름을 복구하는 것을 전제로 한다.
@ -22,6 +22,10 @@ metadata:
- "코레일 예약 확인해줘"
- "KTX 취소해줘"
- "오전 9시 이후 KTX 중 제일 빠른 거 잡아줘"
- "KTX 남은 좌석 번호 확인해줘"
- "이 열차 콘센트 있는 꿀팁 좌석부터 보여줘"
- "KTX 5호차 남은 자리만 봐줘"
- "예약하기 전에 호차별 좌석 확인해줘"
- "N카드로 할인 열차 찾아줘"
- "내 N카드 목록 보여줘"
- "N카드 할인 적용해서 예약해줘"
@ -59,6 +63,7 @@ metadata:
- 희망 시작 시각: `HHMMSS`
- 인원 수와 승객 유형
- 좌석 선호
- 좌석 상세 조건: 객실 등급, 호차 번호, 남은 좌석만 보기, 콘센트 꿀팁 좌석 우선
- 조회 결과에서 복사한 `train_id`
## Workflow
@ -108,7 +113,62 @@ python3 scripts/ktx_booking.py search 남춘천 용산 20260503 150000 --train-t
- 일반실/특실 가능 여부
- 예약 대기 가능 여부
### 4. Reserve only after the target train is unambiguous
### 4. Inspect detailed seats when the user asks for good seats
`search` 의 좌석 가능 여부는 열차 단위 플래그다. 사용자가 "남은 좌석 번호", "호차별 좌석", "콘센트", "꿀팁 좌석", "창측/순방향 자리", "예약 전에 자리 확인"처럼 구체적인 좌석을 물으면 예약 전에 `seats` 를 호출한다.
기본 상세 좌석 조회:
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id>
```
일반실/특실은 `--room` 으로 나눈다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --room special
```
남은 좌석번호만 보고 싶으면 `--available-only` 를 쓴다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안의 좌석은 콘센트 힌트가 있는 좌석(`direct`, `adjacent`)을 먼저, 같은 조건에서는 순방향 좌석을 먼저 보여준다.
특정 호차만 확인하려면 `--car-no` 를 쓴다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --car-no 5 --available-only
```
콘센트 꿀팁 자리부터 확인하려면 `--power-only` 를 붙인다. 응답의 `power_outlet``direct`, `adjacent`, `none` 중 하나다.
```bash
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only --power-only
```
`seats``search` 와 같은 `--train-type` 을 넘겨야 한다. ITX-청춘 등 KTX 외 열차를 조회했다면 상세 좌석 조회에도 같은 값을 사용한다.
```bash
python3 scripts/ktx_booking.py seats 남춘천 용산 20260503 150000 \
--train-id <train_id> \
--train-type itx-cheongchun \
--available-only
```
상세 좌석 응답을 보여줄 때는 사용자 의도에 맞춰 아래를 우선 요약한다.
- 호차별 `remaining_seats`, `available_seat_count`
- 남은 좌석 번호 (`available_seats`)
- 좌석별 `direction`, `position`, `seat_type`
- 콘센트 힌트 (`power_outlet`)
- 문 근처 여부 (`near_door`)
이 기능은 좌석을 선택/선점하지 않는다. 실제 예약은 다음 단계의 `reserve` 로만 진행한다.
### 5. Reserve only after the target train is unambiguous
조회 결과의 `train_id` 를 고른 뒤에만 예약한다. 이 값은 helper 가 열차 번호/운행일/시각/역 코드를 묶어 만든 stable selector 이므로, 재조회 시 같은 열차가 아직 있으면 그대로 잡고 없으면 실패한다.
@ -125,7 +185,7 @@ python3 scripts/ktx_booking.py reserve 남춘천 용산 20260503 150000 --train-
응답에는 예약번호, 운임, 구입기한이 포함된다. **결제는 자동화하지 않는다.**
좌석이 없을 때는 조회 단계에서 `--include-waiting-list` 를 켜고 예약 단계에서 `--try-waiting` 으로 예약 대기까지 시도할 수 있다.
### 4-1. N-card discounted reservation
### 5-1. N-card discounted reservation
N카드 할인을 적용하려면 먼저 보유 N카드 목록을 조회해 카드 번호를 확인한다.
@ -151,7 +211,7 @@ python3 scripts/ktx_booking.py reserve 대전 서울 20260512 100000 \
N카드 기능은 `korail2-ncard` 패키지가 필요하다. 없으면 해당 커맨드 실행 시 설치 안내가 출력된다.
### 5. Inspect or cancel
### 6. Inspect or cancel
취소는 대상 예약을 다시 조회해 식별한 뒤에만 진행한다.
@ -166,6 +226,7 @@ python3 scripts/ktx_booking.py cancel <reservation_id>
## Done when
- 조회면 열차 후보가 정리되어 있다
- 좌석 상세 확인이면 호차별 남은 좌석번호와 필요한 꿀팁 조건이 정리되어 있다
- 예약이면 예약 결과와 제한 시간이 확인되어 있다
- 취소면 어떤 예약을 취소했는지 남아 있다

12
legacy/README.md Normal file
View file

@ -0,0 +1,12 @@
# Legacy Unsupported Code
This directory preserves unsupported skills and helper code that are not part of the default k-skill install, plugin manifest, Manus bundles, npm workspaces, proxy route surface, or README feature list.
Archived items:
- `unsupported-skills/blue-ribbon-nearby/` - Blue Ribbon nearby skill. The upstream blocks automation/premium access in ways this repository cannot currently support.
- `unsupported-skills/naver-map-route/` - Naver Map route skill. NCP Maps operational prerequisites are not currently available for the hosted proxy.
- `unsupported-packages/blue-ribbon-nearby/` - Former npm workspace package retained for future revival.
- `unsupported-proxy/bluer.js` and `unsupported-proxy/naver-map.js` - Former proxy helper modules retained for future revival.
To revive one of these surfaces, move the code back into the normal repo layout, restore docs/tests/proxy routes or workspace metadata, and rerun `npm run ci` plus live/manual QA.

View file

@ -0,0 +1,382 @@
const NAVER_MAP_DIRECTIONS_URL = "https://maps.apigw.ntruss.com/map-direction/v1/driving";
const NAVER_MAP_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-geocode/v2/geocode";
const NAVER_MAP_REVERSE_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc";
const ALLOWED_DRIVING_OPTIONS = new Set([
"trafast",
"tracomfort",
"traoptimal",
"traavoidtoll",
"traavoidcaronly"
]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
if (!trimmed || trimmed === "replace-me") {
return null;
}
return trimmed;
}
function parseFloatValue(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseCoordPair(value, label) {
const trimmed = trimOrNull(value);
if (!trimmed) {
throw new Error(`Provide ${label} as "lng,lat".`);
}
const parts = trimmed.split(",").map((part) => part.trim());
if (parts.length !== 2) {
throw new Error(`Provide ${label} as "lng,lat".`);
}
const lng = parseFloatValue(parts[0]);
const lat = parseFloatValue(parts[1]);
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
throw new Error(`Provide ${label} as numeric "lng,lat".`);
}
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
throw new Error(`Provide valid ${label} coordinates.`);
}
return `${lng},${lat}`;
}
function createNaverMapHttpError(serviceName, responseStatus, bodyText) {
const error = new Error(`Naver Maps ${serviceName} upstream returned an error.`);
error.code = "upstream_error";
const isAuthError = responseStatus === 401 || responseStatus === 403;
error.statusCode = isAuthError ? 503 : responseStatus === 429 ? 429 : 502;
error.upstreamStatusCode = responseStatus;
if (!isAuthError) {
error.upstreamBodySnippet = bodyText.slice(0, 200);
}
return error;
}
function normalizeNaverMapDirectionsQuery(query) {
const start = parseCoordPair(query.start ?? query.from ?? query.origin, "start");
const goal = parseCoordPair(query.goal ?? query.to ?? query.destination, "goal");
const rawWaypoints = query.waypoints ?? query.waypoint;
let waypoints = null;
if (rawWaypoints !== undefined && rawWaypoints !== null && rawWaypoints !== "") {
const entries = Array.isArray(rawWaypoints) ? rawWaypoints : String(rawWaypoints).split("|");
if (entries.length > 5) {
throw new Error("Provide at most 5 waypoints.");
}
waypoints = entries.map((entry, index) => parseCoordPair(entry, `waypoint[${index}]`)).join("|");
}
const rawOption = trimOrNull(query.option);
let option = "trafast";
if (rawOption) {
const candidate = rawOption.toLowerCase();
if (!ALLOWED_DRIVING_OPTIONS.has(candidate)) {
throw new Error(
`Provide option as one of ${[...ALLOWED_DRIVING_OPTIONS].join(", ")}.`
);
}
option = candidate;
}
const lang = trimOrNull(query.lang) || "ko";
return { start, goal, waypoints, option, lang };
}
function normalizeNaverMapGeocodeQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
const coordinate = trimOrNull(query.coordinate);
if (coordinate) {
// validate format only; pass through to upstream
parseCoordPair(coordinate, "coordinate");
}
const filter = trimOrNull(query.filter);
const language = trimOrNull(query.language) || "kor";
const rawPage = trimOrNull(query.page);
const page = rawPage ? Number.parseInt(rawPage, 10) : 1;
if (!Number.isFinite(page) || page < 1 || page > 50) {
throw new Error("Provide page between 1 and 50.");
}
const rawCount = trimOrNull(query.count);
const count = rawCount ? Number.parseInt(rawCount, 10) : 10;
if (!Number.isFinite(count) || count < 1 || count > 100) {
throw new Error("Provide count between 1 and 100.");
}
return { q, coordinate, filter, language, page, count };
}
function normalizeNaverMapReverseGeocodeQuery(query) {
const coords = parseCoordPair(query.coords ?? query.coordinate ?? query.coord, "coords");
const rawOrders = trimOrNull(query.orders);
const orders = rawOrders || "roadaddr,addr";
// basic allowlist guard
const allowed = new Set(["roadaddr", "addr", "legalcode", "admcode"]);
for (const order of orders.split(",")) {
if (!allowed.has(order.trim())) {
throw new Error(`Provide orders from ${[...allowed].join(", ")}.`);
}
}
const output = trimOrNull(query.output) || "json";
if (output !== "json") {
throw new Error("Provide output as json. XML passthrough is not supported by this proxy.");
}
return { coords, orders, output };
}
async function fetchNaverMapDirections({
start,
goal,
waypoints,
option,
lang,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_DIRECTIONS_URL);
url.searchParams.set("start", start);
url.searchParams.set("goal", goal);
if (waypoints) {
url.searchParams.set("waypoints", waypoints);
}
url.searchParams.set("option", option);
url.searchParams.set("lang", lang);
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps directions upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
throw createNaverMapHttpError("directions", response.status, text);
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps directions upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
error.upstreamStatusCode = response.status;
throw error;
}
// Naver returns code !== 0 inside 2xx for semantic failures.
if (body && typeof body.code === "number" && body.code !== 0) {
const error = new Error(body.message || "Naver Maps directions reported a semantic failure.");
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = body.code;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchNaverMapGeocode({
q,
coordinate,
filter,
language,
page,
count,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_GEOCODE_URL);
url.searchParams.set("query", q);
if (coordinate) {
url.searchParams.set("coordinate", coordinate);
}
if (filter) {
url.searchParams.set("filter", filter);
}
url.searchParams.set("language", language);
url.searchParams.set("page", String(page));
url.searchParams.set("count", String(count));
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps geocode upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
throw createNaverMapHttpError("geocode", response.status, text);
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps geocode upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
if (body && body.status && body.status !== "OK") {
const error = new Error(body.errorMessage || `Naver Maps geocode reported status ${body.status}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamStatus = body.status;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchNaverMapReverseGeocode({
coords,
orders,
output,
clientId,
clientSecret,
fetchImpl = global.fetch
}) {
if (!clientId || !clientSecret) {
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(NAVER_MAP_REVERSE_GEOCODE_URL);
url.searchParams.set("coords", coords);
url.searchParams.set("orders", orders);
url.searchParams.set("output", output);
let response;
try {
response = await fetchImpl(url, {
headers: {
"x-ncp-apigw-api-key-id": clientId,
"x-ncp-apigw-api-key": clientSecret,
accept: "application/json",
"user-agent": "k-skill-proxy/naver-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Naver Maps reverse-geocode upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
throw createNaverMapHttpError("reverse-geocode", response.status, text);
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Naver Maps reverse-geocode upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
if (body && body.status && body.status.code !== undefined && body.status.code !== 0) {
const error = new Error(body.status.message || `Naver Maps reverse-geocode reported code ${body.status.code}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = body.status.code;
throw error;
}
return { statusCode: response.status, contentType, body };
}
module.exports = {
NAVER_MAP_DIRECTIONS_URL,
NAVER_MAP_GEOCODE_URL,
NAVER_MAP_REVERSE_GEOCODE_URL,
ALLOWED_DRIVING_OPTIONS,
fetchNaverMapDirections,
fetchNaverMapGeocode,
fetchNaverMapReverseGeocode,
normalizeNaverMapDirectionsQuery,
normalizeNaverMapGeocodeQuery,
normalizeNaverMapReverseGeocodeQuery
};

View file

@ -0,0 +1,182 @@
---
name: naver-map-route
description: 네이버 지도(NAVER Cloud Platform Maps) 기반 출발지→목적지 자동차 길찾기·지오코딩·역지오코딩을 k-skill-proxy 경유로 조회한다. 수동 입력 MVP, mock 기본, live opt-in.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Naver Map Route (네이버 지도 길찾기 MVP)
> ⚠️ **현재 미작동 (2026-05-25)**: NCP Maps 운영자 키가 프록시 서버에 아직 설정되지 않아 live 모드가 동작하지 않습니다. mock fallback만 사용 가능합니다. NCP 결제수단 등록 완료 후 키를 설정하면 이 안내를 제거합니다.
## What this skill does
사용자가 `/route` 또는 `/이동루트` 명령으로 출발지·목적지를 직접 입력하면, **NAVER Cloud Platform Maps Directions 5** 결과를 `k-skill-proxy` 경유로 조회하여 거리·소요 시간·통행료·연료비를 요약한다.
- 운영자가 NCP Maps key를 proxy 서버 쪽에만 보관하고, 사용자는 별도 key가 필요하지 않다.
- 기본 모드는 **mock**이다. 명시 활성화(`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` + `ROUTE_PLANNER_PROVIDER=naver`)될 때만 live proxy 호출을 수행한다.
- 키 누락·인증 실패 시 graceful fallback으로 mock 결과를 안내한다.
이슈 #268 의 MVP 수용 기준:
- [x] `/route` 수동 입력 정상 응답
- [x] `/이동루트` 수동 입력 정상 응답
- [x] 기본 mock 모드에서 안정 동작
- [x] live 명시 활성화 + 키 존재 시 naver provider 선택
- [x] 키 누락/실패 시 fallback 응답
- [x] secret/token/.env 원문 미노출
## When to use
- "/route 강남역에서 시청역" 같은 한 줄 수동 입력
- "/이동루트 출발: <주소> / 도착: <주소>"
- "강남역에서 시청까지 차로 얼마나 걸려?" (수동 좌표/주소 입력으로 변환 후 길찾기)
- 자동차 기준 경로 요약, 거리·소요 시간·통행료·연료비 확인
## When NOT to use
- 도보·자전거·대중교통 경로 (대중교통은 기존 `korean-transit-route` 스킬, 도보·자전거는 별도 스킬)
- 실시간 교통 변동을 1분 단위로 추적하는 작업 (proxy cache가 있음)
- 현재 위치 자동 인식 / 캘린더 연동 (MVP 범위 밖)
## Prerequisites
- Python 3 표준 라이브러리만 사용한다 (`urllib`, `argparse`, `json`).
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org`).
- optional: `ROUTE_PLANNER_PROVIDER=naver` (값이 `naver`일 때만 live provider 후보).
- optional: `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` (live 호출을 명시 허용).
## Required environment variables
사용자 머신에는 **필요 없다.** 운영자가 proxy 서버 쪽에 다음을 둔다:
- `NAVER_MAP_CLIENT_ID` — NCP Maps subaccount client id
- `NAVER_MAP_CLIENT_SECRET` — NCP Maps subaccount client secret
proxy 서버가 이 키 없이 가동되면 `/v1/naver-map/*` 라우트는 `503 upstream_not_configured` 를 돌려준다. 클라이언트는 이를 mock fallback 신호로 사용한다.
## Decision flow
```
provider 결정
├── ROUTE_PLANNER_ENABLE_LIVE_PROVIDER != "true"
│ → mock 결과 반환
├── ROUTE_PLANNER_PROVIDER != "naver"
│ → mock 결과 반환
└── live 시도
├── proxy /v1/naver-map/directions 호출
├── 503 / 502 / 네트워크 실패
│ → mock fallback + warning 메모
└── 정상 응답
→ 요약 + provider="naver"
```
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 (`/map-direction/v1/driving`) | `start=lng,lat`, `goal=lng,lat`, `waypoints` (최대 5), `option=trafast\|tracomfort\|traoptimal\|traavoidtoll\|traavoidcaronly`, `lang=ko` |
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding (`/map-geocode/v2/geocode`) | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding (`/map-reversegeocode/v2/gc`) | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
## Workflow
### 1. 사용자 입력 정리
- `/route <start>, <goal>` 또는 `/이동루트 출발: <start> 도착: <goal>` 패턴을 받는다.
- 좌표(`126.9706,37.5559`) 또는 주소(`강남역 1번 출구`) 둘 다 허용. 주소는 geocode 단계로 좌표를 얻는다.
### 2. mock 모드 (기본)
`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER` 가 비어 있거나 `true`가 아니면 즉시 mock 결과를 만든다:
```json
{
"provider": "mock",
"start": { "label": "강남역", "lng": null, "lat": null },
"goal": { "label": "시청역", "lng": null, "lat": null },
"summary": {
"distance_km": null,
"duration_minutes": null,
"toll_won": null,
"fuel_won": null
},
"note": "live provider is disabled. Set ROUTE_PLANNER_PROVIDER=naver and ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true to call the proxy."
}
```
### 3. live 모드
`ROUTE_PLANNER_PROVIDER=naver` + `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true`:
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/naver-map/directions" \
--data-urlencode 'start=126.9706,37.5559' \
--data-urlencode 'goal=127.0276,37.4979' \
--data-urlencode 'option=trafast'
```
응답에서 기본 `option=trafast` 기준 `route.trafast[0].summary` 를 읽고, 다른 option을 명시한 경우 `route[option][0].summary` 를 다음으로 매핑한다:
- `distance` (meter) → `distance_km = distance / 1000`
- `duration` (millisecond) → `duration_minutes = duration / 60000`
- `tollFare``toll_won`
- `fuelPrice``fuel_won`
### 4. 주소 → 좌표 변환 (필요할 때만)
사용자가 좌표를 모르고 주소만 줬을 때:
```bash
curl -fsS --get "${BASE}/v1/naver-map/geocode" \
--data-urlencode 'q=강남역 1번 출구' \
--data-urlencode 'count=1'
```
응답의 `addresses[0].x` (lng), `addresses[0].y` (lat) 를 사용한다.
### 5. 출력 포맷
```
[mock 모드]
경로 요약 (mock): 강남역 → 시청역
- 거리/소요시간/통행료 정보 없음
- live 활성화 방법: ROUTE_PLANNER_PROVIDER=naver, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true
[live 모드]
경로 요약 (naver): 강남역(126.9706,37.5559) → 시청역(127.0276,37.4979)
- 거리: 12.3km
- 예상 소요시간: 25분
- 통행료: 1,200원
- 연료비: 1,500원
- 옵션: trafast
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## Failure modes
- proxy upstream key 미설정 (`NAVER_MAP_CLIENT_ID/SECRET` 없음) → `503 upstream_not_configured` → mock fallback
- NCP Maps 인증 실패 (401/403) → proxy가 `503` 으로 변환 → mock fallback
- NCP Maps quota/rate-limit (`429`) → proxy가 `429 upstream_error` 로 보존 → mock fallback + 재시도 간격 안내
- 경로 미발견 (`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 좌표 형식 오류 → `400 bad_request`
- 네트워크 실패 → `502 upstream_error` → mock fallback
## Done when
- 사용자가 `/route` 또는 `/이동루트` 로 출발지·목적지를 줬을 때, mock 또는 live 결과로 한 가지가 명확히 응답된다.
- live 응답에는 거리/시간/통행료/연료비/조회 시각이 정리되어 있다.
- secret/token/.env 원문은 응답에 절대 노출되지 않는다.
- live 실패 시 mock fallback 이 작동하고, fallback 임을 사용자에게 명시한다.
## Notes
- 본 MVP는 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 별도 스킬을 사용한다.
- waypoints 는 최대 5개 (NCP Maps 정책).
- option=`trafast`(빠른 경로) 가 기본. 정확한 정의는 NCP Maps Directions 5 공식 문서를 참고.
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.
- 현재 위치 자동 인식·캘린더 읽기는 의도적으로 범위에서 제외된다(이슈 #268 OUT).

View file

@ -0,0 +1,114 @@
# 네이버맵 길찾기 가이드
> ⚠️ **현재 미작동 (2026-05-25)**: NCP Maps 운영자 키(`NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET`)가 아직 프록시 서버에 설정되지 않아 모든 `/v1/naver-map/*` 라우트가 `503 upstream_not_configured`를 반환합니다. 스킬은 mock fallback으로 동작합니다. NCP 결제수단 등록이 완료되는 대로 키를 설정하고 이 안내를 제거할 예정입니다.
## 이 기능으로 할 수 있는 일
- 출발지·목적지를 좌표(`lng,lat`) 또는 주소로 받아 NAVER Cloud Platform Maps Directions 5 결과를 `k-skill-proxy` 경유로 조회
- 자동차 경로의 거리·소요 시간·통행료·연료비 요약
- 주소 → 좌표(Naver Geocoding), 좌표 → 주소(Reverse Geocoding) 보조 조회
- `/route`, `/이동루트` 명령으로 호출되는 instruction-level 워크플로
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 확인
- 사용자는 별도 NAVER Map key 발급 필요 없음
- 운영자(proxy 서버)는 NAVER_MAP_CLIENT_ID·NAVER_MAP_CLIENT_SECRET 보유
## 기본 경로
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/naver-map/*`
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
## Provider 결정
| 환경변수 | 효과 |
|---|---|
| `ROUTE_PLANNER_PROVIDER=naver` | naver provider 활성화 후보 |
| `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` | live proxy 호출 명시 허용 |
| 둘 중 하나라도 미설정 | mock 결과 반환 |
이 게이트는 **기본을 mock으로 잠그는 안전장치**다. 명시 활성화 없이 운영자 proxy를 호출하지 않는다.
## Proxy routes
| endpoint | upstream | 주요 입력 |
|---|---|---|
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 | `start=lng,lat`, `goal=lng,lat`, `waypoints` (`\|` 구분 최대 5), `option`(trafast 기본), `lang=ko` |
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
## 기본 흐름
1. client/skill 은 `/route` 또는 `/이동루트` 명령으로 출발지·목적지 수동 입력을 받는다.
2. provider 결정 게이트를 확인한다 (`ROUTE_PLANNER_*` 환경변수).
3. mock 모드: 형식만 갖춘 응답을 즉시 반환하고 `provider: "mock"` 표기.
4. live 모드:
- 주소만 있으면 `/v1/naver-map/geocode` 로 좌표를 얻는다.
- `/v1/naver-map/directions` 로 경로를 조회한다.
- 기본 `option=trafast` 응답은 `route.trafast[0].summary` 를, 다른 option을 명시한 경우 `route[option][0].summary` 를 거리/시간/통행료/연료비로 매핑한다.
5. live 실패(503/502/네트워크) 시 mock fallback 으로 떨어지고, 사용자에게 fallback 임을 명시한다.
## 예시
mock 모드:
```bash
ROUTE_PLANNER_ENABLE_LIVE_PROVIDER= # 또는 미설정
# 결과
# {
# "provider": "mock",
# "start": { "label": "강남역" },
# "goal": { "label": "시청역" },
# "summary": { "distance_km": null, "duration_minutes": null, "toll_won": null, "fuel_won": null },
# "note": "live provider is disabled."
# }
```
live 모드 (proxy 직접 호출 예시):
```bash
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
curl -fsS --get "${BASE}/v1/naver-map/directions" \
--data-urlencode 'start=126.9706,37.5559' \
--data-urlencode 'goal=127.0276,37.4979' \
--data-urlencode 'option=trafast'
```
응답 예상 요약:
```text
경로 요약 (naver): 시청역(126.9706,37.5559) → 강남역(127.0276,37.4979)
- 거리: 12.3km
- 예상 소요시간: 25분
- 통행료: 1,200원
- 연료비: 1,500원
- 옵션: trafast
- 조회 시각: 2026-05-23T14:00:00.000Z
```
## fallback / 대체 흐름
- 키 누락(`503 upstream_not_configured`) → mock fallback + 사용자에게 안내
- 인증 실패(401/403) → proxy 가 `503` 으로 변환 → mock fallback
- quota/rate-limit(429) → proxy 가 `429 upstream_error` 로 보존 → mock fallback + 재시도 간격 안내
- 경로 미발견(`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
- 네트워크 실패 → `502 upstream_error` → mock fallback
- 좌표 형식 오류 → `400 bad_request`
## 주의할 점
- 본 스킬은 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 다른 스킬을 사용한다.
- 현재 위치 자동 인식과 캘린더 읽기는 의도적으로 범위에서 제외된다 (이슈 #268 OUT).
- waypoints 는 최대 5개 (NCP Maps 정책).
- option 값은 `trafast`(빠른 경로), `tracomfort`(편안), `traoptimal`(최적), `traavoidtoll`(통행료 회피), `traavoidcaronly`(자동차전용 회피) 중 하나.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버 측에서만 주입).
## 참고 표면
- NAVER Cloud Platform Maps Console: `https://www.ncloud.com/product/applicationService/maps`
- Maps Directions 5 endpoint: `https://maps.apigw.ntruss.com/map-direction/v1/driving`
- Maps Geocoding endpoint: `https://maps.apigw.ntruss.com/map-geocode/v2/geocode`
- Maps Reverse Geocoding endpoint: `https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc`
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)

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

38
package-lock.json generated
View file

@ -560,10 +560,6 @@
"node": ">=4"
}
},
"node_modules/blue-ribbon-nearby": {
"resolved": "packages/blue-ribbon-nearby",
"link": true
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
@ -1744,16 +1740,6 @@
"node": ">= 8"
}
},
"packages/blue-ribbon-nearby": {
"version": "0.2.3",
"license": "MIT",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"rebrowser-playwright": ">=1.0.0"
}
},
"packages/cheap-gas-nearby": {
"version": "0.4.0",
"license": "MIT",
@ -1762,7 +1748,7 @@
}
},
"packages/court-auction-notice-search": {
"version": "0.2.0",
"version": "0.3.0",
"license": "MIT",
"bin": {
"court-auction-notice-search": "bin/court-auction-notice-search.js"
@ -1776,7 +1762,7 @@
}
},
"packages/daishin-report-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"daishin-report-search": "src/cli.js"
@ -1786,28 +1772,28 @@
}
},
"packages/daiso-product-search": {
"version": "0.2.0",
"version": "0.6.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/donation-place-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/emergency-room-beds": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"gangnamunni-clinic-search": "src/cli.js"
@ -1817,7 +1803,7 @@
}
},
"packages/gongsijiga-search": {
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"engines": {
"node": ">=18"
@ -1844,7 +1830,7 @@
}
},
"packages/k-skill-proxy": {
"version": "0.2.0",
"version": "0.6.0",
"license": "MIT",
"dependencies": {
"fastify": "^5.3.3"
@ -1888,7 +1874,7 @@
}
},
"packages/korean-marathon-schedule": {
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"bin": {
"korean-marathon-schedule": "src/cli.js"
@ -1905,7 +1891,7 @@
}
},
"packages/local-election-candidate-search": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"bin": {
"local-election-candidate-search": "src/cli.js"
@ -1936,7 +1922,7 @@
}
},
"packages/sh-notice-search": {
"version": "0.1.0",
"version": "0.4.0",
"license": "MIT",
"bin": {
"sh-notice-search": "src/cli.js"
@ -1946,7 +1932,7 @@
}
},
"packages/toss-securities": {
"version": "0.3.0",
"version": "0.4.0",
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -10,10 +10,12 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.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 && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"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 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",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && 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 && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.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 blue-ribbon-nearby --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",
"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_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",
"release:npm": "changeset publish"

View file

@ -1,5 +1,17 @@
# daiso-product-search
## 0.6.0
### Minor Changes
- 7c2dc59: Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
## 0.5.0
### Minor Changes
- 01cd887: Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.
## 0.4.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "daiso-product-search",
"version": "0.4.0",
"version": "0.6.0",
"description": "Official Daiso Mall store/product search and pickup-stock client",
"license": "MIT",
"main": "src/index.js",

View file

@ -1,5 +1,17 @@
# emergency-room-beds
## 0.4.0
### Minor Changes
- 4e2d1fa: Add an E-Gen based nearby emergency-room status skill and package.
## 0.3.0
### Minor Changes
- 01cd887: Add an E-Gen based nearby emergency-room status skill and package.
## 0.2.0
### Minor Changes

View file

@ -1,6 +1,6 @@
{
"name": "emergency-room-beds",
"version": "0.2.0",
"version": "0.4.0",
"description": "Public E-Gen nearby emergency room status lookup for Korean location queries",
"license": "MIT",
"main": "src/index.js",

View file

@ -1,5 +1,32 @@
# 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
- 6d49a28: Add Kakao Map proxy routes (keyword search, category search, coord2address, coord2region, Kakao Mobility car directions) used by the new kakao-map skill (issue #267). All routes inject server-side KAKAO_REST_API_KEY and never forward caller-supplied apiKey query params.
- ff2aa91: Add NAVER Cloud Platform Maps directions, geocoding, and reverse-geocoding proxy routes used by the new naver-map-route skill (issue #268). Routes inject server-side NAVER_MAP_CLIENT_ID/SECRET and return 503 when the upstream key is missing.
- 540e80b: Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원\_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).
- e6d7072: Add Seoul Bike realtime, station master, and nearby lookup proxy routes.
## 0.5.0
### Minor Changes
- 01cd887: Add `/v1/kstartup/{business-info,announcements,contents,statistics}` routes that wrap the data.go.kr `15125364` (창업진흥원\_K-Startup) Open API. The routes inject `DATA_GO_KR_API_KEY` server-side, return 503 when the key (or the per-dataset 활용신청) is missing, and cache successful JSON responses while bypassing the cache for upstream error envelopes (`resultCode != "00"`).
## 0.4.0
### Minor Changes

View file

@ -25,6 +25,11 @@
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
- `GET /v1/kakao-map/search/keyword` — Kakao Local 키워드 장소 검색(좌표 중심·반경·카테고리 필터 지원, `KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/search/category` — Kakao Local 카테고리 장소 검색(좌표 중심 필수, `KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2address` — Kakao Local 좌표→도로명/지번 주소(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2region` — Kakao Local 좌표→행정구역(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
@ -61,7 +66,7 @@
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao Local REST API 키 (`kakao-local/geocode`)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao REST API 키 (`kakao-local/geocode`, `kakao-map/*`, `kakao-mobility/directions`)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시

View file

@ -1,6 +1,6 @@
{
"name": "k-skill-proxy",
"version": "0.4.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/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.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,515 @@
const KAKAO_LOCAL_API_BASE_URL = "https://dapi.kakao.com/v2/local";
const KAKAO_MOBILITY_API_BASE_URL = "https://apis-navi.kakaomobility.com/v1";
// Kakao Local category group codes (공식)
const KAKAO_CATEGORY_GROUP_CODES = new Set([
"MT1", // 대형마트
"CS2", // 편의점
"PS3", // 어린이집, 유치원
"SC4", // 학교
"AC5", // 학원
"PK6", // 주차장
"OL7", // 주유소, 충전소
"SW8", // 지하철역
"BK9", // 은행
"CT1", // 문화시설
"AG2", // 중개업소
"PO3", // 공공기관
"AT4", // 관광명소
"AD5", // 숙박
"FD6", // 음식점
"CE7", // 카페
"HP8", // 병원
"PM9" // 약국
]);
const KAKAO_MOBILITY_PRIORITY = new Set(["RECOMMEND", "TIME", "DISTANCE"]);
const KAKAO_MOBILITY_CAR_FUEL = new Set(["GASOLINE", "DIESEL", "LPG"]);
const KAKAO_MOBILITY_ROAD_DETAILS = new Set(["true", "false"]);
const KAKAO_MOBILITY_AVOID = new Set(["ferries", "toll", "motorway", "schoolzone", "uturn"]);
function trimOrNull(value) {
if (value === undefined || value === null) {
return null;
}
const trimmed = String(value).trim();
if (!trimmed || trimmed === "replace-me") {
return null;
}
return trimmed;
}
function parseFloatOrNaN(value) {
if (value === undefined || value === null || value === "") {
return Number.NaN;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : Number.NaN;
}
function parseBoundedPositiveInteger(value, { defaultValue, min, max, label }) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || String(parsed) !== String(value).trim()) {
throw new Error(`Provide ${label} as a positive integer.`);
}
if (parsed < min || parsed > max) {
throw new Error(`Provide ${label} between ${min} and ${max}.`);
}
return parsed;
}
function normalizeKakaoKeywordSearchQuery(query) {
const q = trimOrNull(query.q ?? query.query);
if (!q) {
throw new Error("Provide query.");
}
const result = {
query: q,
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 15,
min: 1,
max: 15,
label: "size"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
})
};
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
const hasX = xRaw !== undefined && xRaw !== null && xRaw !== "";
const hasY = yRaw !== undefined && yRaw !== null && yRaw !== "";
if (hasX !== hasY) {
throw new Error("Provide both x (lng) and y (lat) for coordinate-centered search.");
}
if (hasX && hasY) {
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
result.x = String(x);
result.y = String(y);
}
const radius = query.radius;
if (radius !== undefined && radius !== null && radius !== "") {
if (!result.x || !result.y) {
throw new Error("Provide both x (lng) and y (lat) when using radius.");
}
result.radius = parseBoundedPositiveInteger(radius, {
defaultValue: undefined,
min: 0,
max: 20000,
label: "radius"
});
if (result.radius === undefined) {
delete result.radius;
}
}
const categoryGroupCode = trimOrNull(query.category_group_code ?? query.categoryGroupCode);
if (categoryGroupCode) {
if (!KAKAO_CATEGORY_GROUP_CODES.has(categoryGroupCode)) {
throw new Error(`Provide category_group_code from documented Kakao Local codes.`);
}
result.category_group_code = categoryGroupCode;
}
const sort = trimOrNull(query.sort);
if (sort) {
if (sort !== "distance" && sort !== "accuracy") {
throw new Error("Provide sort as 'distance' or 'accuracy'.");
}
if (sort === "distance" && (!result.x || !result.y)) {
throw new Error("Provide both x (lng) and y (lat) when using sort=distance.");
}
result.sort = sort;
}
return result;
}
function normalizeKakaoCategorySearchQuery(query) {
const categoryGroupCode = trimOrNull(query.category_group_code ?? query.categoryGroupCode);
if (!categoryGroupCode || !KAKAO_CATEGORY_GROUP_CODES.has(categoryGroupCode)) {
throw new Error("Provide category_group_code from documented Kakao Local codes.");
}
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
if (xRaw === undefined || yRaw === undefined || xRaw === "" || yRaw === "") {
throw new Error("Provide both x (lng) and y (lat).");
}
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
const result = {
category_group_code: categoryGroupCode,
x: String(x),
y: String(y),
radius: parseBoundedPositiveInteger(query.radius, {
defaultValue: 500,
min: 0,
max: 20000,
label: "radius"
}),
page: parseBoundedPositiveInteger(query.page, {
defaultValue: 1,
min: 1,
max: 45,
label: "page"
}),
size: parseBoundedPositiveInteger(query.size ?? query.limit, {
defaultValue: 15,
min: 1,
max: 15,
label: "size"
})
};
const sort = trimOrNull(query.sort);
if (sort) {
if (sort !== "distance" && sort !== "accuracy") {
throw new Error("Provide sort as 'distance' or 'accuracy'.");
}
result.sort = sort;
}
return result;
}
function normalizeKakaoCoordToAddressQuery(query) {
const xRaw = query.x ?? query.lng ?? query.longitude;
const yRaw = query.y ?? query.lat ?? query.latitude;
if (xRaw === undefined || yRaw === undefined || xRaw === "" || yRaw === "") {
throw new Error("Provide both x (lng) and y (lat).");
}
const x = parseFloatOrNaN(xRaw);
const y = parseFloatOrNaN(yRaw);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error("Provide x and y as numeric coordinates.");
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error("Provide valid x and y coordinates.");
}
const result = { x: String(x), y: String(y) };
const inputCoord = trimOrNull(query.input_coord ?? query.inputCoord);
if (inputCoord) {
if (!["WGS84", "WCONGNAMUL", "CONGNAMUL", "WTM", "TM"].includes(inputCoord)) {
throw new Error("Provide input_coord as one of WGS84, WCONGNAMUL, CONGNAMUL, WTM, TM.");
}
result.input_coord = inputCoord;
}
return result;
}
function normalizeKakaoMobilityDirectionsQuery(query) {
const originRaw = trimOrNull(query.origin);
const destinationRaw = trimOrNull(query.destination);
if (!originRaw || !destinationRaw) {
throw new Error("Provide origin and destination as 'x,y'.");
}
for (const [label, value] of [["origin", originRaw], ["destination", destinationRaw]]) {
const parts = value.split(",").map((p) => p.trim());
if (parts.length !== 2) {
throw new Error(`Provide ${label} as 'x,y'.`);
}
const x = parseFloatOrNaN(parts[0]);
const y = parseFloatOrNaN(parts[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(`Provide ${label} as numeric 'x,y'.`);
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error(`Provide valid ${label} coordinates.`);
}
}
const rawWaypoints = query.waypoints ?? query.waypoint;
let waypoints = null;
if (rawWaypoints !== undefined && rawWaypoints !== null && rawWaypoints !== "") {
const entries = Array.isArray(rawWaypoints) ? rawWaypoints : String(rawWaypoints).split("|");
if (entries.length > 5) {
throw new Error("Provide at most 5 waypoints.");
}
for (const [index, entry] of entries.entries()) {
const parts = entry.split(",").map((p) => p.trim());
if (parts.length !== 2) {
throw new Error(`Provide waypoint[${index}] as numeric 'x,y'.`);
}
const x = parseFloatOrNaN(parts[0]);
const y = parseFloatOrNaN(parts[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new Error(`Provide waypoint[${index}] as numeric 'x,y'.`);
}
if (x < -180 || x > 180 || y < -90 || y > 90) {
throw new Error(`Provide valid waypoint[${index}] coordinates.`);
}
}
waypoints = entries.join("|");
}
const priority = (trimOrNull(query.priority) || "RECOMMEND").toUpperCase();
if (!KAKAO_MOBILITY_PRIORITY.has(priority)) {
throw new Error(`Provide priority as one of ${[...KAKAO_MOBILITY_PRIORITY].join(", ")}.`);
}
const carFuelRaw = trimOrNull(query.car_fuel ?? query.carFuel);
let carFuel = null;
if (carFuelRaw) {
const upper = carFuelRaw.toUpperCase();
if (!KAKAO_MOBILITY_CAR_FUEL.has(upper)) {
throw new Error(`Provide car_fuel as one of ${[...KAKAO_MOBILITY_CAR_FUEL].join(", ")}.`);
}
carFuel = upper;
}
const carHipassRaw = trimOrNull(query.car_hipass ?? query.carHipass);
let carHipass = null;
if (carHipassRaw) {
const lower = carHipassRaw.toLowerCase();
if (!KAKAO_MOBILITY_ROAD_DETAILS.has(lower)) {
throw new Error("Provide car_hipass as 'true' or 'false'.");
}
carHipass = lower === "true";
}
const alternativesRaw = trimOrNull(query.alternatives);
let alternatives = null;
if (alternativesRaw) {
const lower = alternativesRaw.toLowerCase();
if (!KAKAO_MOBILITY_ROAD_DETAILS.has(lower)) {
throw new Error("Provide alternatives as 'true' or 'false'.");
}
alternatives = lower === "true";
}
const avoidRaw = trimOrNull(query.avoid);
let avoid = null;
if (avoidRaw) {
const values = avoidRaw.split("|").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
if (values.length === 0 || values.some((entry) => !KAKAO_MOBILITY_AVOID.has(entry))) {
throw new Error(`Provide avoid as pipe-separated values from ${[...KAKAO_MOBILITY_AVOID].join(", ")}.`);
}
avoid = values.join("|");
}
return {
origin: originRaw,
destination: destinationRaw,
waypoints,
priority,
car_fuel: carFuel,
car_hipass: carHipass,
alternatives,
avoid
};
}
async function fetchKakaoLocalEndpoint({
endpoint,
params = {},
apiKey,
fetchImpl = global.fetch
}) {
const paths = {
keyword: "search/keyword.json",
category: "search/category.json",
address: "search/address.json",
coord2address: "geo/coord2address.json",
coord2region: "geo/coord2regioncode.json"
};
const path = paths[endpoint];
if (!path) {
const error = new Error("That Kakao Local endpoint is not exposed by this proxy.");
error.code = "not_found";
error.statusCode = 404;
throw error;
}
if (!apiKey) {
const error = new Error("KAKAO_REST_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(`${KAKAO_LOCAL_API_BASE_URL}/${path}`);
for (const [key, value] of Object.entries(params || {})) {
if (value === undefined || value === null || value === "" || key === "apiKey") {
continue;
}
url.searchParams.set(key, String(value));
}
let response;
try {
response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
"user-agent": "k-skill-proxy/kakao-map"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Kakao Local upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Kakao Local upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Kakao Local upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
return { statusCode: response.status, contentType, body };
}
async function fetchKakaoMobilityDirections({
origin,
destination,
waypoints,
priority,
car_fuel,
car_hipass,
alternatives,
avoid,
apiKey,
fetchImpl = global.fetch
}) {
if (!apiKey) {
const error = new Error("KAKAO_REST_API_KEY is not configured on the proxy server.");
error.code = "upstream_not_configured";
error.statusCode = 503;
throw error;
}
const url = new URL(`${KAKAO_MOBILITY_API_BASE_URL}/directions`);
url.searchParams.set("origin", origin);
url.searchParams.set("destination", destination);
if (waypoints) {
url.searchParams.set("waypoints", waypoints);
}
url.searchParams.set("priority", priority);
if (car_fuel !== null && car_fuel !== undefined) {
url.searchParams.set("car_fuel", car_fuel);
}
if (car_hipass !== null && car_hipass !== undefined) {
url.searchParams.set("car_hipass", String(car_hipass));
}
if (alternatives !== null && alternatives !== undefined) {
url.searchParams.set("alternatives", String(alternatives));
}
if (avoid) {
url.searchParams.set("avoid", avoid);
}
let response;
try {
response = await fetchImpl(url, {
headers: {
authorization: `KakaoAK ${apiKey}`,
accept: "application/json",
"user-agent": "k-skill-proxy/kakao-mobility"
},
signal: AbortSignal.timeout(20000)
});
} catch (fetchError) {
const error = new Error("Failed to reach Kakao Mobility directions upstream.");
error.code = "upstream_error";
error.statusCode = 502;
error.cause = fetchError;
throw error;
}
const text = await response.text();
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
if (response.status < 200 || response.status >= 300) {
const error = new Error("Kakao Mobility directions upstream returned an error.");
error.code = "upstream_error";
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
error.upstreamStatusCode = response.status;
error.upstreamBodySnippet = text.slice(0, 200);
throw error;
}
let body;
try {
body = JSON.parse(text);
} catch (parseError) {
const error = new Error("Kakao Mobility directions upstream returned non-JSON.");
error.code = "upstream_parse_error";
error.statusCode = 502;
error.cause = parseError;
throw error;
}
// Kakao Mobility returns routes[].result_code !== 0 for semantic failures.
if (body && Array.isArray(body.routes) && body.routes.length > 0) {
const firstRoute = body.routes[0];
const code = firstRoute && firstRoute.result_code;
if (typeof code === "number" && code !== 0) {
const error = new Error(firstRoute.result_msg || `Kakao Mobility reported result_code ${code}.`);
error.code = "upstream_semantic_error";
error.statusCode = 502;
error.upstreamStatusCode = response.status;
error.upstreamCode = code;
throw error;
}
}
return { statusCode: response.status, contentType, body };
}
module.exports = {
KAKAO_LOCAL_API_BASE_URL,
KAKAO_MOBILITY_API_BASE_URL,
KAKAO_CATEGORY_GROUP_CODES,
KAKAO_MOBILITY_PRIORITY,
KAKAO_MOBILITY_CAR_FUEL,
KAKAO_MOBILITY_AVOID,
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
normalizeKakaoKeywordSearchQuery,
normalizeKakaoCategorySearchQuery,
normalizeKakaoCoordToAddressQuery,
normalizeKakaoMobilityDirectionsQuery
};

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
};

Some files were not shown because too many files have changed in this diff Show more