mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Compare commits
1 commit
main
...
feature/#3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35b8207561 |
63 changed files with 1972 additions and 4938 deletions
5
.changeset/archive-unsupported-map-skills.md
Normal file
5
.changeset/archive-unsupported-map-skills.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": patch
|
||||
---
|
||||
|
||||
Archive unsupported Naver Map and Blue Ribbon proxy support. The proxy no longer registers `/v1/naver-map/*` or `/v1/blue-ribbon/nearby`, and the unsupported skill/package code is preserved under `legacy/` for a future revival if operational blockers are resolved.
|
||||
5
.changeset/toss-securities-official-openapi.md
Normal file
5
.changeset/toss-securities-official-openapi.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"toss-securities": minor
|
||||
---
|
||||
|
||||
Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
"repository": "https://github.com/NomaDamas/k-skill",
|
||||
"license": "MIT",
|
||||
"skills": [
|
||||
"./biz-health-check",
|
||||
"./bunjang-search",
|
||||
"./catchtable-sniper",
|
||||
"./cheap-gas-nearby",
|
||||
|
|
@ -30,8 +29,6 @@
|
|||
"./fine-dust-location",
|
||||
"./flight-ticket-search",
|
||||
"./foresttrip-vacancy",
|
||||
"./fsc-corporate-info",
|
||||
"./g2b-sanctioned-supplier",
|
||||
"./gangnamunni-clinic-search",
|
||||
"./geeknews-search",
|
||||
"./gongsijiga-search",
|
||||
|
|
@ -42,7 +39,6 @@
|
|||
"./hwp",
|
||||
"./intercity-bus-booking",
|
||||
"./iros-registry-automation",
|
||||
"./jobkorea-talent-search",
|
||||
"./joseon-sillok-search",
|
||||
"./k-dart",
|
||||
"./k-schoollunch-menu",
|
||||
|
|
@ -76,18 +72,15 @@
|
|||
"./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",
|
||||
|
|
@ -95,7 +88,6 @@
|
|||
"./real-estate-search",
|
||||
"./rhwp-advanced",
|
||||
"./rhwp-edit",
|
||||
"./saramin-talent-search",
|
||||
"./seoul-bike",
|
||||
"./seoul-density",
|
||||
"./seoul-subway-arrival",
|
||||
|
|
|
|||
1
.github/workflows/deploy-k-skill-proxy.yml
vendored
1
.github/workflows/deploy-k-skill-proxy.yml
vendored
|
|
@ -88,7 +88,6 @@ jobs:
|
|||
KOSIS_API_KEY=KOSIS_API_KEY:latest
|
||||
NAVER_SEARCH_CLIENT_ID=NAVER_SEARCH_CLIENT_ID:latest
|
||||
NAVER_SEARCH_CLIENT_SECRET=NAVER_SEARCH_CLIENT_SECRET:latest
|
||||
LAW_OC=LAW_OC:latest
|
||||
env_vars: |-
|
||||
KSKILL_PROXY_HOST=0.0.0.0
|
||||
KSKILL_PROXY_NAME=k-skill-proxy
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,5 @@ __pycache__/
|
|||
dist/
|
||||
.sisyphus/
|
||||
.omo/
|
||||
.gjc/
|
||||
|
||||
.agents/
|
||||
|
|
|
|||
18
README.md
18
README.md
|
|
@ -27,7 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
|
||||
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
|
||||
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
|
||||
| 카카오톡 Mac 아카이브 검색 | `kakaotalk-mac` | `katok`으로 macOS 카카오톡 로컬 아카이브를 동기화하고 keyword/BM25/semantic 검색 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md) |
|
||||
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
|
||||
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
|
||||
|
|
@ -42,12 +42,6 @@ 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) |
|
||||
|
|
@ -66,8 +60,6 @@ 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) |
|
||||
|
|
@ -159,7 +151,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [고속버스 예매](docs/features/express-bus-booking.md)
|
||||
- [시외버스 예매](docs/features/intercity-bus-booking.md)
|
||||
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
|
||||
- [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md)
|
||||
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
|
||||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
|
||||
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
|
||||
|
|
@ -172,12 +164,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
---
|
||||
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`)의 공식 출처를 따른다.
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -119,7 +119,7 @@ for s in \
|
|||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY \
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY \
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY \
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC; do
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET; do
|
||||
gcloud secrets add-iam-policy-binding "$s" \
|
||||
--project="$PROJECT_ID" \
|
||||
--member="serviceAccount:${RUNTIME_SA}" \
|
||||
|
|
@ -159,7 +159,7 @@ KEYS=(
|
|||
AIR_KOREA_OPEN_API_KEY KMA_OPEN_API_KEY SEOUL_OPEN_API_KEY HRFCO_OPEN_API_KEY
|
||||
OPINET_API_KEY DATA_GO_KR_API_KEY KEDU_INFO_KEY
|
||||
DATA4LIBRARY_AUTH_KEY FOODSAFETYKOREA_API_KEY KAKAO_REST_API_KEY KRX_API_KEY
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET LAW_OC
|
||||
KOSIS_API_KEY NAVER_SEARCH_CLIENT_ID NAVER_SEARCH_CLIENT_SECRET
|
||||
)
|
||||
|
||||
set -a; source ~/.config/k-skill/secrets.env; set +a
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
# 사업자 실사 종합 (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)의 "사업자 실사" 항목 참조.
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 금융위 기업기본정보 조회 (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`
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# 부정당제재업체 조회 (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`
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# 잡코리아 인재검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 잡코리아 기업회원 인재검색 화면에서 구인/채용 조건을 입력해 후보를 찾는다.
|
||||
- 사용자가 직접 로그인한 브라우저 세션에서 현재 보이는 마스킹된 목록/이력서 정보를 읽는다.
|
||||
- 유료 이력서 열람 전에 후보 적합도를 비교하고 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를 조정해야 할 수 있다.
|
||||
- 계정 권한, 유료 상품 상태, 마스킹 정책에 따라 보이는 정보가 다르다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -1,113 +1,106 @@
|
|||
# 카카오톡 Mac 아카이브 검색 가이드
|
||||
# 카카오톡 Mac CLI 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- Apple Silicon macOS에서 `katok`으로 카카오톡 로컬 대화 아카이브 생성
|
||||
- keyword, BM25, semantic 검색
|
||||
- 검색 결과의 chunk id로 원문, 주변 맥락, parent window 조회
|
||||
- 검색 전 freshness 확인과 sync/index 필요 여부 판단
|
||||
|
||||
이 가이드는 기존 `kakaotalk-mac` 스킬 경로를 유지하지만 실행 표면은 `katok` CLI다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 포함하지 않는다.
|
||||
- macOS에서 카카오톡 최근 대화 목록 확인
|
||||
- 특정 채팅방 최근 메시지 읽기
|
||||
- 키워드로 전체 대화 검색
|
||||
- 나와의 채팅으로 안전하게 테스트 전송
|
||||
- 사용자 확인 후 특정 채팅방으로 메시지 전송
|
||||
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- Apple Silicon macOS
|
||||
- macOS
|
||||
- KakaoTalk for Mac 설치
|
||||
- Homebrew 또는 Cargo
|
||||
- `katok` CLI
|
||||
- 현재 터미널 앱의 Full Disk Access 권한
|
||||
- Homebrew
|
||||
- `brew install silver-flight-group/tap/kakaocli`
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
|
||||
## 설치
|
||||
|
||||
Homebrew:
|
||||
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
|
||||
|
||||
```bash
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
brew install mas
|
||||
mas account
|
||||
mas install 869223134
|
||||
```
|
||||
|
||||
Cargo:
|
||||
## 입력값
|
||||
|
||||
```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 원문을 조회한다.
|
||||
- 채팅방 이름
|
||||
- 검색 키워드
|
||||
- 최근 범위(`--since 1h`, `--since 7d` 등)
|
||||
- 전송 메시지 본문
|
||||
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부(`--me`, `--dry-run`)
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
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`로 연다.
|
||||
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. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
## 검색 방식 선택
|
||||
## helper 가 해결하는 문제
|
||||
|
||||
`katok search keyword`는 정확한 문자열, 이름, 계좌번호, 고유명사처럼 그대로 기억나는 값을 찾을 때 쓴다.
|
||||
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
|
||||
|
||||
`katok search bm25`는 여러 단어가 섞인 일반 질의에 쓴다.
|
||||
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
|
||||
- `kakaocli status` 는 정상이어도 `auth` 는 `user_id 자동 감지 실패` 로 끝날 수 있다.
|
||||
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
|
||||
|
||||
`katok search semantic`은 표현이 정확히 기억나지 않지만 의미가 비슷한 대화를 찾을 때 쓴다. `katok doctor --json`에서 semantic index 갱신이 필요하다고 나오면 먼저 `katok index --json`을 실행한다.
|
||||
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
|
||||
|
||||
## chunk 조회
|
||||
- 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 에서 수행한다.
|
||||
|
||||
```bash
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
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
|
||||
```
|
||||
|
||||
- `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를 사용하지 않는다.
|
||||
- `--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 이다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 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 전용이며 메시지 전송과 삭제를 지원하지 않는다.
|
||||
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
|
||||
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
|
||||
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
|
||||
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
|
||||
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
|
||||
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
|
|
|
|||
|
|
@ -2,101 +2,126 @@
|
|||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- `k-skill-proxy` 로 법령명/조문/판례/유권해석/자치법규 검색
|
||||
- 검색 결과 식별자로 조문·판례 본문(상세) 조회
|
||||
- 별도 API key나 로컬 설치 없이 hosted proxy로 바로 사용
|
||||
- `korean-law-mcp` 로 법령명 검색
|
||||
- 특정 법령의 조문 본문 조회
|
||||
- 판례 / 유권해석 / 자치법규 검색
|
||||
- MCP 또는 CLI 경로 중 현재 환경에 맞는 방식 선택
|
||||
- 기존 경로 장애 시 `법망` fallback으로 이어가기
|
||||
|
||||
## 가장 중요한 규칙
|
||||
|
||||
한국 법령 관련 검색/조회는 기본 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` 를 참고했습니다.
|
||||
한국 법령 관련 검색/조회가 필요할 때는 **`korean-law-mcp`를 먼저 사용**합니다.
|
||||
기존 서비스가 동작하지 않을 때만 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 전환합니다.
|
||||
별도 repo package, 별도 python package, 임의 크롤러를 새로 만들지 않습니다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- 인터넷 연결
|
||||
- (선택) `KSKILL_PROXY_BASE_URL` — self-host proxy를 쓸 때만
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- remote MCP endpoint를 쓸 MCP 클라이언트
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
|
||||
사용자는 별도 API key를 준비할 필요가 없습니다. upstream `LAW_OC` 는 proxy 서버에서만 주입합니다. 무료 발급처(운영자용): `https://open.law.go.kr`
|
||||
무료 API key 발급처: `https://open.law.go.kr`
|
||||
|
||||
## 기본 경로
|
||||
|
||||
`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조)로 넘깁니다.
|
||||
|
||||
## 예시
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
|
||||
```bash
|
||||
# 법령명 검색
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=law' \
|
||||
--data-urlencode 'query=관세법'
|
||||
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=prec' \
|
||||
--data-urlencode 'query=부당해고'
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
```
|
||||
|
||||
# 판례 본문 조회
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'ID=228541'
|
||||
로컬 설치가 막히면 먼저 `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조"
|
||||
```
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 질의가 법령/판례/행정해석/자치법규 중 어디에 가까운지 분류한다.
|
||||
2. 법령명만 찾으면 `target=law` 로 `search` 한다.
|
||||
3. 특정 조문이 필요하면 `search` 로 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 을 호출한다.
|
||||
4. 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
|
||||
5. 범주가 애매하면 `target=law` 부터 시작한다.
|
||||
6. 검색 결과가 0건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
|
||||
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건이어도 바로 "관련 규범이 없다"고 단정하지 말고 검색어와 범주를 다시 확인한다.
|
||||
|
||||
## 실패 모드
|
||||
## CLI 예시
|
||||
|
||||
- `target` 이 없거나 허용되지 않은 값이면 400 응답
|
||||
- 검색어/식별자가 없으면 400 응답
|
||||
- 프록시 서버에 `LAW_OC` 가 없으면 503 응답
|
||||
- 법제처 API가 사용자 검증 실패를 반환하면 502 + `law_user_verification_failed` (운영자가 서버 OC/UA/Referer 점검)
|
||||
- 법제처 API가 일시적으로 빈/HTML 응답이면 proxy가 재시도 후 502 + `upstream_unstable`
|
||||
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다.
|
||||
```bash
|
||||
korean-law search_law --query "관세법"
|
||||
korean-law get_law_text --mst 160001 --jo "제38조"
|
||||
korean-law search_precedents --query "부당해고"
|
||||
```
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- `화관법` 같은 약칭은 `target=law` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 번호가 헷갈리면 `detail` 전에 법령 식별자부터 다시 확인한다.
|
||||
- `화관법` 같은 약칭은 `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으로 쓴다.
|
||||
- 요약은 할 수 있지만 법률 자문처럼 단정적으로 결론을 내리지는 않는다.
|
||||
|
||||
## 출처
|
||||
## 라이브 확인 메모
|
||||
|
||||
- 설계 참고(upstream): `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- 공식 데이터 출처: 법제처 국가법령정보 공동활용 (`https://open.law.go.kr`, DRF `lawSearch.do`/`lawService.do`)
|
||||
- 운영자(proxy) 전용 시크릿: `LAW_OC` (사용자는 불필요)
|
||||
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을 사용할 수 있다.
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
# 인허가 영업상태 조회 (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>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# 국민연금 가입 사업장 조회 (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`
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# 국세 체납 명단공개 검색 (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>
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# 사람인 인재풀 검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 사람인 기업회원 인재풀에서 구인/채용 조건에 맞는 후보를 검색한다.
|
||||
- 사용자가 직접 로그인/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`로 낮은 신뢰도를 표시한다.
|
||||
- 후보 개인정보를 장기 저장하거나 대량 수집하지 않는다.
|
||||
|
|
@ -129,7 +129,19 @@ npx --yes skills add <owner/repo> \
|
|||
--skill fine-dust-location
|
||||
```
|
||||
|
||||
`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)를 본다.
|
||||
`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으로 사용한다.
|
||||
|
||||
`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)를 본다.
|
||||
|
||||
|
|
@ -319,22 +331,14 @@ HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm ins
|
|||
|
||||
### macOS 바이너리
|
||||
|
||||
카카오톡 Mac 아카이브 검색은 npm 패키지가 아니라 `katok` CLI 설치를 사용한다.
|
||||
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
|
||||
|
||||
```bash
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
brew install silver-flight-group/tap/kakaocli
|
||||
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
|
||||
|
|
|
|||
|
|
@ -80,6 +80,6 @@ KSKILL_PROXY_BASE_URL=
|
|||
- `KRX_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
`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`)를 사용한다.
|
||||
`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`)를 사용한다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -44,7 +44,9 @@ 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` 을 채운다.
|
||||
|
||||
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
|
||||
한국 법령 검색의 로컬 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`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
|
|
@ -78,7 +80,8 @@ bash scripts/check-setup.sh
|
|||
| 고속버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 KOBUS HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
|
||||
| 시외버스 예매 | 사용자 시크릿 불필요 (조회·좌석 단계는 공식 티머니 HTTP 흐름 사용, 결제는 공식 페이지에서 수동 진행) |
|
||||
| 자연휴양림 빈 객실 조회 | `KSKILL_FORESTTRIP_ID`, `KSKILL_FORESTTRIP_PASSWORD` |
|
||||
| 한국 법령 검색 | 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`) |
|
||||
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
|
||||
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
|
||||
| 한국 부동산 실거래가 조회 | 사용자 시크릿 불필요 (기본 hosted proxy 사용) |
|
||||
| 한국 특허 정보 검색 | `KIPRIS_PLUS_API_KEY` |
|
||||
| 하이패스 영수증 발급 | 사용자 시크릿 불필요 (로그인된 브라우저 세션 필요) |
|
||||
|
|
|
|||
|
|
@ -93,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
|
||||
- 법제처 국가법령정보 공동활용 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
|
||||
- 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
|
||||
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
|
||||
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
|
||||
- 바른한글 메인: https://nara-speller.co.kr/speller/
|
||||
|
|
@ -220,17 +220,3 @@
|
|||
- **기술보증기금**: 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차 인증 후 현재 보이는 마스킹 후보 정보를 읽는 브라우저 기반 경로. 유료 열람/연락처 확인/제안 발송은 수동 확인 대상.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
---
|
||||
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`
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
---
|
||||
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`
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
---
|
||||
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.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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 = ""
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
#!/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)
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
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",
|
||||
},
|
||||
)
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
#!/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()
|
||||
|
|
@ -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` 을 채운다.
|
||||
|
||||
한국 법령 검색은 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)의 `/v1/korean-law/...` endpoint를 경유하므로 사용자 쪽 `LAW_OC` 가 불필요하다. self-host proxy 운영자만 서버 환경변수 `LAW_OC` 를 채운다(무료 발급: `https://open.law.go.kr`).
|
||||
한국 법령 검색은 로컬 `korean-law-mcp` 경로를 쓸 때만 `LAW_OC` 를 채운다. remote endpoint는 사용자 `LAW_OC` 없이 `url`만 등록하면 되고, 기존 경로 장애 시에는 `법망`(`https://api.beopmang.org`)을 fallback으로 안내한다.
|
||||
|
||||
한국 부동산 실거래가 조회는 기본 hosted proxy(`k-skill-proxy.nomadamas.org`)를 경유하므로 사용자 쪽 `DATA_GO_KR_API_KEY` 가 불필요하다.
|
||||
|
||||
|
|
@ -115,7 +115,8 @@ 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`
|
||||
- 한국 법령 검색: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `LAW_OC`)
|
||||
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
|
||||
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
|
||||
- 한국 부동산 실거래가 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용)
|
||||
- 한국 특허 정보 검색: `KIPRIS_PLUS_API_KEY`
|
||||
- 한국 주식 정보 조회: 사용자 시크릿 불필요 (기본 hosted proxy 사용, 운영자만 `KRX_API_KEY`)
|
||||
|
|
|
|||
|
|
@ -1,199 +1,223 @@
|
|||
---
|
||||
name: kakaotalk-mac
|
||||
description: Search local KakaoTalk archives on Apple Silicon macOS through the katok CLI.
|
||||
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: messaging
|
||||
locale: ko-KR
|
||||
phase: v2
|
||||
phase: v1.5
|
||||
---
|
||||
|
||||
# KakaoTalk katok Search
|
||||
# KakaoTalk Mac CLI
|
||||
|
||||
## What this skill does
|
||||
|
||||
`katok` CLI를 유일한 실행 표면으로 사용해 macOS 카카오톡 대화를 로컬 아카이브와 검색 인덱스로 동기화하고, keyword/BM25/semantic 검색과 chunk 조회를 수행한다.
|
||||
`kakaocli` 와 이 저장소 helper를 사용해 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.
|
||||
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "카카오톡에서 특정 키워드 검색해줘"
|
||||
- "카톡에서 지난 회의/계약/약속 이야기 찾아줘"
|
||||
- "이 검색 결과 chunk를 열어줘"
|
||||
- "최근 대화가 반영됐는지 확인하고 검색해줘"
|
||||
- "카카오톡 최근 대화 목록 보여줘"
|
||||
- "특정 채팅방 최근 메시지 찾아줘"
|
||||
- "카카오톡 메시지 검색해줘"
|
||||
- "내 카톡으로 테스트 메시지 보내줘"
|
||||
- "답장 초안은 만들되 실제 전송 전에는 꼭 확인받아"
|
||||
|
||||
## When not to use
|
||||
|
||||
- macOS가 아닌 환경
|
||||
- Intel Mac에서 로컬 EmbeddingGemma semantic index가 필요한 경우
|
||||
- 카카오톡 메시지를 보내거나 삭제해야 하는 경우
|
||||
- 카카오톡 DB 파일, 인증 캐시, 복호화 material을 직접 다루라는 요청
|
||||
- 서버-투-서버 공식 Kakao API 연동 요청
|
||||
- 카카오톡 Mac 앱이 설치되어 있지 않은 환경
|
||||
- 사용자 확인 없이 다른 사람에게 메시지를 바로 보내야 하는 작업
|
||||
- 카카오 공식 API 범위 안에서 해결 가능한 서버-투-서버 연동 작업
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Apple Silicon macOS
|
||||
- macOS
|
||||
- KakaoTalk for Mac 설치
|
||||
- Homebrew 또는 Cargo
|
||||
- `katok` CLI 설치
|
||||
- 현재 터미널 앱에 Full Disk Access 권한
|
||||
- Homebrew
|
||||
- Mac App Store 로그인(`mas` 사용 시)
|
||||
- `kakaocli` 설치
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
|
||||
## Install katok
|
||||
## Inputs
|
||||
|
||||
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
|
||||
```
|
||||
- 채팅방 이름 또는 검색 키워드
|
||||
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d` 등
|
||||
- 전송할 메시지 본문
|
||||
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부 (`--me`, `--dry-run`)
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Check readiness without prompting for app data
|
||||
### 0. Install KakaoTalk for Mac first when missing
|
||||
|
||||
카카오톡 Mac 앱이 없으면 먼저 설치한다. `mas` 를 쓰려면 App Store 로그인 상태여야 한다.
|
||||
|
||||
```bash
|
||||
katok doctor --json
|
||||
brew install mas
|
||||
mas account
|
||||
mas install 869223134
|
||||
```
|
||||
|
||||
`doctor --json`의 `freshness` 섹션에서 마지막 sync/index 상태를 확인한다. 이 기본 doctor는 macOS app-data probe를 실행하지 않으므로 권한 prompt를 띄우지 않는 준비 상태 점검에 적합하다.
|
||||
`mas install` 이 막히면 App Store 앱에서 먼저 로그인한 뒤 다시 시도한다.
|
||||
|
||||
### 2. Open macOS permission settings when needed
|
||||
### 1. Install `kakaocli`
|
||||
|
||||
Full Disk Access 설정이 필요하면 사용자가 직접 허용할 수 있도록 설정 화면을 연다.
|
||||
공식 저장소 기준 권장 설치는 Homebrew tap 이다.
|
||||
|
||||
```bash
|
||||
katok permissions macos
|
||||
brew install silver-flight-group/tap/kakaocli
|
||||
```
|
||||
|
||||
KakaoTalk UI 자동화는 이 스킬 범위가 아니지만, upstream 진단을 위해 Accessibility 설정 화면까지 열어야 하는 경우에만 다음 명령을 쓴다.
|
||||
설치 후 바로 상태를 확인한다.
|
||||
|
||||
```bash
|
||||
katok permissions macos --accessibility
|
||||
kakaocli status
|
||||
```
|
||||
|
||||
### 3. Run explicit macOS setup diagnostics only when needed
|
||||
### 2. Grant the required macOS permissions
|
||||
|
||||
카카오톡 앱 설치, container, DB 파일 접근 같은 macOS source adapter 상태를 확인해야 할 때만 probe를 실행한다. 이 명령은 macOS가 app-data 접근 prompt를 띄울 수 있다.
|
||||
**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
|
||||
|
||||
먼저 읽기 경로가 되는지 확인한다.
|
||||
|
||||
```bash
|
||||
katok doctor --macos-probe --json
|
||||
kakaocli status
|
||||
kakaocli auth
|
||||
kakaocli chats --limit 10 --json
|
||||
```
|
||||
|
||||
### 4. Sync local KakaoTalk archives
|
||||
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
|
||||
|
||||
최신 대화가 중요하거나 `freshness.recommendation.sync_before_search`가 true이면 검색 전에 sync한다.
|
||||
### 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** 를 함께 제공한다.
|
||||
|
||||
```bash
|
||||
katok sync --source macos --json
|
||||
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
|
||||
```
|
||||
|
||||
설정 파일의 기본 source adapter를 쓰는 경우:
|
||||
- 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
|
||||
|
||||
```bash
|
||||
katok sync --json
|
||||
kakaocli messages --chat "지수" --since 1h --json
|
||||
kakaocli search "점심" --json
|
||||
```
|
||||
|
||||
### 5. Build or refresh the semantic index
|
||||
|
||||
semantic search 전 `freshness.recommendation.index_before_semantic_search`가 true이거나 방금 sync한 내용을 semantic 검색에 반영해야 하면 index를 만든다.
|
||||
helper 경유 예시:
|
||||
|
||||
```bash
|
||||
katok index --json
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
|
||||
python3 scripts/kakaotalk_mac.py search "점심" --json
|
||||
```
|
||||
|
||||
`katok index`는 기본적으로 로컬 `embeddinggemma-300m-q4` embedder를 사용한다. Python, Jina, TEI, 별도 HTTP embedding server를 요구하지 않는다.
|
||||
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
|
||||
|
||||
### 6. Search with the narrowest useful mode
|
||||
### 5. Use safe testing before real sends
|
||||
|
||||
정확한 문자열, 이름, 계좌번호, 고유명사는 keyword search를 먼저 쓴다.
|
||||
실제 전송 전에 먼저 자기 자신에게 테스트하거나 dry-run 으로 확인한다.
|
||||
|
||||
```bash
|
||||
katok search keyword "검색어" --json
|
||||
kakaocli send --me _ "테스트 메시지"
|
||||
kakaocli send --dry-run "채팅방 이름" "보낼 문장"
|
||||
```
|
||||
|
||||
여러 단어가 섞인 일반 질의는 BM25를 쓴다.
|
||||
`--me` 는 나와의 채팅으로 보내므로 가장 안전한 테스트 경로다.
|
||||
|
||||
### 6. Confirm before sending to other people
|
||||
|
||||
다른 사람이나 단체방으로 보내기 전에는 반드시 사용자의 최종 확인을 받는다.
|
||||
|
||||
확인 전에는 아래만 준비한다.
|
||||
|
||||
- 대상 채팅방 이름
|
||||
- 전송할 문장
|
||||
- 왜 이 문장을 보내는지 한 줄 설명
|
||||
|
||||
확인을 받았을 때만 전송한다.
|
||||
|
||||
```bash
|
||||
katok search bm25 "지난주 미팅 자료" --json
|
||||
kakaocli send "채팅방 이름" "보낼 문장"
|
||||
```
|
||||
|
||||
표현이 정확히 기억나지 않는 의미 기반 질의는 semantic search를 쓴다.
|
||||
### 7. Delete a sent message only with explicit operator intent
|
||||
|
||||
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
|
||||
|
||||
```bash
|
||||
katok search semantic "최근에 논의한 세금 신고 일정" --json
|
||||
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
|
||||
```
|
||||
|
||||
### 7. Retrieve explicit chunks only when needed
|
||||
주의:
|
||||
|
||||
검색 결과는 먼저 snippet과 chunk id 중심으로 요약한다. 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 원문 chunk를 조회한다.
|
||||
- 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
|
||||
|
||||
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
|
||||
|
||||
```bash
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
kakaocli login
|
||||
kakaocli login --status
|
||||
```
|
||||
|
||||
- `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
|
||||
|
||||
- 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` 결과를 요약했다.
|
||||
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
|
||||
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
|
||||
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
|
||||
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `katok` 미설치 또는 Cargo binary PATH 누락
|
||||
- Apple Silicon macOS가 아님
|
||||
- KakaoTalk for Mac 미설치
|
||||
- App Store 로그인 누락으로 `mas install` 실패
|
||||
- Full Disk Access 미부여
|
||||
- `katok doctor --macos-probe --json`에서 container 또는 DB 파일 접근 실패
|
||||
- sync 전이라 local archive가 비어 있음
|
||||
- semantic index가 오래되었거나 아직 생성되지 않음
|
||||
- 검색 결과가 snippet/chunk id만으로 충분하지 않아 명시적 chunk 조회가 필요함
|
||||
- Accessibility 미부여
|
||||
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
|
||||
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 read/search/retrieve 전용이다.
|
||||
- 메시지 전송과 삭제는 지원하지 않는다.
|
||||
- DB 내부 구조, auth cache, decryption material은 직접 다루지 않는다.
|
||||
- 기존 설치 이름은 `kakaotalk-mac`이지만 실행 표면은 `katok`이다.
|
||||
- 이 스킬은 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` 을 사용한다.
|
||||
|
|
|
|||
1001
kakaotalk-mac/scripts/kakaotalk_mac.py
Normal file
1001
kakaotalk-mac/scripts/kakaotalk_mac.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: korean-humanizer
|
||||
description: 'AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.'
|
||||
description: AI가 쓴 티가 나는 한국어 글을 자연스러운 사람 글로 고친다. 번역체, AI 상투어, 과도한 명사화·피동, 3의 법칙, 과장된 의의 부여, 마무리 상투구, 챗봇 잔재, 줄표·곡선따옴표 같은 한국어 특유의 AI 흔적을 심각도(S1/S2/S3)로 분류해 잡아내고 의미는 보존하면서 다시 쓴다. 목표 글자수를 함께 주면(예: "1000자로", length=1000) 그 분량에 맞춰 늘리거나 줄인다. "AI 티 안 나게", "사람이 쓴 것처럼", "자연스럽게 다듬어줘", "번역체 고쳐줘", "어색한 거 고쳐줘", "N자로 맞춰서" 같은 요청에 사용.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: writing
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: korean-law-search
|
||||
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.
|
||||
description: Use korean-law-mcp first for Korean law lookups, and fall back to Beopmang when the primary service is unavailable.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: legal
|
||||
|
|
@ -12,12 +12,16 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
기본적으로 `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 도구 표면을 참고했다.
|
||||
한국 법령/조문/판례/유권해석/자치법규 조회가 필요할 때 기본 경로로 **`korean-law-mcp`를 먼저 사용**하고, 기존 서비스가 동작하지 않을 때는 승인된 fallback 표면인 **`법망`(`https://api.beopmang.org`)** 으로 이어간다.
|
||||
|
||||
사용자는 별도 API key(`LAW_OC`)나 로컬 CLI 설치가 필요 없다. `LAW_OC` 와 브라우저 User-Agent/Referer 주입은 proxy 서버에서만 처리한다.
|
||||
- 법령명 검색: `search_law`
|
||||
- 조문 본문 조회: `get_law_text`
|
||||
- 판례 검색: `search_precedents`
|
||||
- 유권해석 검색: `search_interpretations`
|
||||
- 자치법규 검색: `search_ordinance`
|
||||
- 여러 카테고리가 섞인 검색: `search_all`
|
||||
|
||||
- 검색/목록: `GET /v1/korean-law/search`
|
||||
- 본문/상세: `GET /v1/korean-law/detail`
|
||||
이 스킬은 자체 npm/python 패키지를 만들지 않는다. 한국 법령 관련 조회는 기본적으로 `korean-law-mcp` 로 처리하고, 해당 경로가 막히거나 실패가 반복될 때만 승인된 fallback 표면인 `법망`을 사용한다.
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -35,102 +39,136 @@ metadata:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream `LAW_OC` 는 proxy 서버에서만 주입한다.
|
||||
- 인터넷 연결
|
||||
- `node` 18+
|
||||
- `npm install -g korean-law-mcp` (로컬 CLI/로컬 MCP server 경로일 때)
|
||||
- MCP 클라이언트에 remote endpoint를 등록할 수 있는 환경
|
||||
- `법망` fallback (`https://api.beopmang.org`) 에 접근할 수 있는 네트워크
|
||||
|
||||
## Default path
|
||||
무료 API key: `https://open.law.go.kr`
|
||||
|
||||
추가 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
|
||||
|
||||
법령명 검색:
|
||||
로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC` 가 필요하다.
|
||||
remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=law' \
|
||||
--data-urlencode 'query=관세법'
|
||||
npm install -g korean-law-mcp
|
||||
export LAW_OC=your-api-key
|
||||
|
||||
korean-law list
|
||||
korean-law help search_law
|
||||
```
|
||||
|
||||
판례 검색:
|
||||
로컬 설치가 운영체제 정책이나 권한 때문에 막히면 먼저 `korean-law-mcp` 의 remote MCP endpoint(`https://korean-law-mcp.fly.dev/mcp`)를 사용한다. 그래도 기존 경로가 응답하지 않거나 서비스 장애로 조회가 막히면, 승인된 fallback 표면인 `법망` MCP/REST(`https://api.beopmang.org`)로 전환한다.
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/search' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'query=부당해고'
|
||||
## 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 -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/korean-law/detail' \
|
||||
--data-urlencode 'target=prec' \
|
||||
--data-urlencode 'ID=228541'
|
||||
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조"
|
||||
```
|
||||
|
||||
## 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 "개인정보 처리방침 행정해석"
|
||||
```
|
||||
|
||||
## Response policy
|
||||
|
||||
- 한국 법령 관련 요청은 이 proxy endpoint로 처리한다. 별도 크롤러나 검색엔진 우회로 넘어가지 않는다.
|
||||
- 약칭(`화관법`)이면 `target=law` 로 정식 법령명을 먼저 확인한다.
|
||||
- 조문 요청이면 검색 결과의 식별자(`MST`/`ID`)를 확인한 뒤 `detail` 로 본문을 가져온다.
|
||||
- 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 조회한다.
|
||||
- 판례 본문이 필요하면 검색 결과의 판례 일련번호를 `detail?target=prec&ID=...` 로 이어서 조회한다.
|
||||
- 검색 결과가 0건이어도 "관련 규범이 없다"고 단정하지 말고 검색어·법원·사건번호·선고일자·출처명을 바꿔 다시 시도한다.
|
||||
- 일부 출처는 본문을 제공하지 않을 수 있다. 본문을 못 가져오면 목록 메타데이터(사건번호·법원·선고일자·출처·요지)까지만 제공하고 본문이 없다는 점을 명시한다(없는 본문을 지어내지 않는다).
|
||||
- 한국 법령 관련 요청은 **항상 `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` 등록 상태만 확인한다.
|
||||
- 법적 판단이 필요한 경우 `검색 결과 요약`과 `원문 출처`까지만 제공하고 법률 자문처럼 단정하지 않는다.
|
||||
|
||||
## 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
|
||||
|
||||
- 한국 법령 관련 질의를 proxy endpoint로 라우팅했다.
|
||||
- 법령/조문은 `target=law` + 필요 시 `detail`, 판례는 `target=prec`, 유권해석은 `target=expc`, 자치법규는 `target=ordin` 로 맞는 종류를 조회했다.
|
||||
- 판례/조문 본문이 필요하면 식별자로 `detail` 본문까지 연결했다.
|
||||
- 결과를 요약하고 원문 출처(법제처 국가법령정보센터)를 함께 남겼다.
|
||||
- 한국 법령 관련 질의에 대해 `korean-law-mcp` 사용 경로가 선택되었다.
|
||||
- 필요한 검색/조회 명령이 정해졌다.
|
||||
- 법령/조문/판례/유권해석/자치법규 중 맞는 도구로 결과를 조회했다.
|
||||
- 유권해석이면 `search_interpretations`, 자치법규면 `search_ordinance` 까지 명시적으로 연결했다.
|
||||
- 로컬 경로라면 `LAW_OC` 확보 방법을 정확한 변수 이름으로 안내했다.
|
||||
- remote endpoint라면 사용자 `LAW_OC` 없이 `url` 등록 상태를 확인했다.
|
||||
- 기존 경로 장애 시 `법망` fallback(MCP 또는 REST)으로 이어지는 안내가 포함되었다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 설계 참고(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`
|
||||
- upstream: `https://github.com/chrisryugj/korean-law-mcp`
|
||||
- fallback surface: `https://api.beopmang.org`
|
||||
- official data source: 법제처 Open API (`https://open.law.go.kr`)
|
||||
- 이 저장소 안에는 한국 법령 전용 npm package나 python package를 추가하지 않는다.
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
{
|
||||
"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": "문화_청소년게임제공업"
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
{
|
||||
"서울특별시 본청": "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"
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
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`
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
---
|
||||
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`
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
"""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())
|
||||
|
|
@ -11,10 +11,10 @@
|
|||
"build": "npm run build --workspaces --if-present",
|
||||
"build:manus-bundle": "node scripts/build-manus-bundle.js",
|
||||
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py 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",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_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",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "k-skill-proxy",
|
||||
"version": "0.7.0",
|
||||
"version": "0.6.0",
|
||||
"private": true,
|
||||
"description": "Fastify proxy for k-skill upstream APIs",
|
||||
"license": "MIT",
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/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",
|
||||
"lint": "node --check src/airkorea.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
// 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
|
||||
};
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
// National Pension Service (NPS) workplace-coverage API wrapper.
|
||||
// Proxies data.go.kr 3046071 (NpsBplcInfoInqireServiceV2) XML endpoints and
|
||||
// keeps the operator's DATA_GO_KR_API_KEY server-side.
|
||||
//
|
||||
// The upstream returns business registration numbers masked to the first 6
|
||||
// digits, so identity is established by (workplace name + 6-digit prefix) only.
|
||||
// When more than one candidate matches we return the candidate list as-is and
|
||||
// do not assert which one is the queried business.
|
||||
|
||||
const NPS_BASE_URL = "https://apis.data.go.kr/B552015/NpsBplcInfoInqireServiceV2";
|
||||
|
||||
// data.go.kr gateway-level auth/quota reason codes (OpenAPI_ServiceResponse).
|
||||
const AUTH_REASON_CODES = new Set(["20", "21", "30", "31", "32", "33"]);
|
||||
|
||||
function trimOrNull(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function digitsOnly(value) {
|
||||
return String(value ?? "").replace(/[^0-9]/g, "");
|
||||
}
|
||||
|
||||
// Accepts wkplNm (workplace/business name) and an optional business
|
||||
// registration number whose first 6 digits are used as a prefix filter.
|
||||
function normalizeNationalPensionQuery(query = {}) {
|
||||
const wkplNm = trimOrNull(query.wkplNm ?? query.name ?? query.b_nm);
|
||||
if (!wkplNm) {
|
||||
throw new Error(
|
||||
"Provide wkplNm (workplace/business name). The NPS API only discloses the first 6 digits of the business number, so a name is required."
|
||||
);
|
||||
}
|
||||
const rawBno = trimOrNull(query.b_no ?? query.bno ?? query.bzowrRgstNo);
|
||||
const bnoDigits = rawBno ? digitsOnly(rawBno) : "";
|
||||
if (rawBno && !/^\d{10}$/.test(bnoDigits)) {
|
||||
throw new Error("Provide b_no as a 10-digit business registration number.");
|
||||
}
|
||||
const bnoPrefix = bnoDigits ? bnoDigits.slice(0, 6) : "";
|
||||
return { wkplNm, bnoPrefix };
|
||||
}
|
||||
|
||||
// Regex-based parser for the flat <item> structure data.go.kr returns.
|
||||
// Not a general-purpose XML parser — sufficient for NPS responses.
|
||||
function parseNationalPensionXml(xmlText) {
|
||||
const text = String(xmlText ?? "");
|
||||
|
||||
if (text.includes("<OpenAPI_ServiceResponse")) {
|
||||
const reasonCode = (text.match(/<returnReasonCode>([^<]*)<\/returnReasonCode>/) || [])[1]?.trim() || "";
|
||||
const authMsg = (text.match(/<returnAuthMsg>([^<]*)<\/returnAuthMsg>/) || [])[1]?.trim() || "";
|
||||
const kind = AUTH_REASON_CODES.has(reasonCode) ? "auth-error" : "error";
|
||||
return { kind, reason: `${authMsg || "SERVICE ERROR"} (code ${reasonCode})`.trim() };
|
||||
}
|
||||
|
||||
const resultCode = (text.match(/<resultCode>([^<]*)<\/resultCode>/) || [])[1]?.trim() || "";
|
||||
const resultMsg = (text.match(/<resultMsg>([^<]*)<\/resultMsg>/) || [])[1]?.trim() || "";
|
||||
if (resultCode && !["00", "0"].includes(resultCode)) {
|
||||
return { kind: "error", reason: `resultCode=${resultCode} ${resultMsg}`.trim() };
|
||||
}
|
||||
|
||||
const items = [];
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
|
||||
let itemMatch;
|
||||
while ((itemMatch = itemRegex.exec(text)) !== null) {
|
||||
const obj = {};
|
||||
const fieldRegex = /<(\w+)>([\s\S]*?)<\/\1>/g;
|
||||
let fieldMatch;
|
||||
while ((fieldMatch = fieldRegex.exec(itemMatch[1])) !== null) {
|
||||
obj[fieldMatch[1]] = fieldMatch[2].trim();
|
||||
}
|
||||
items.push(obj);
|
||||
}
|
||||
|
||||
const totalCount = (text.match(/<totalCount>([^<]*)<\/totalCount>/) || [])[1]?.trim() || "";
|
||||
return { kind: "items", items, totalCount };
|
||||
}
|
||||
|
||||
async function callOperation(operation, params, serviceKey, fetchImpl) {
|
||||
const url = new URL(`${NPS_BASE_URL}/${operation}`);
|
||||
url.searchParams.set("serviceKey", serviceKey);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const doFetch = fetchImpl || global.fetch;
|
||||
let response;
|
||||
try {
|
||||
response = await doFetch(url.toString(), { signal: AbortSignal.timeout(20000) });
|
||||
} catch (err) {
|
||||
return { kind: "error", reason: `Upstream request failed: ${err.message}` };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { kind: "auth-error", reason: `HTTP ${response.status}` };
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = (await response.text()).slice(0, 80).trim();
|
||||
return { kind: "error", reason: `upstream HTTP ${response.status} ${body}`.trim() };
|
||||
}
|
||||
return parseNationalPensionXml(await response.text());
|
||||
}
|
||||
|
||||
// Orchestrates the three NPS operations: basic search → dedup → (when a single
|
||||
// candidate is identified) detail + monthly-status. Mirrors the reference Python
|
||||
// provider so the proxy returns a clean, structured result with the key never
|
||||
// leaving the server.
|
||||
async function fetchNationalPensionWorkplace({ wkplNm, bnoPrefix = "", serviceKey, fetchImpl = global.fetch }) {
|
||||
const basic = await callOperation(
|
||||
"getBassInfoSearchV2",
|
||||
{ wkplNm, bzowrRgstNo: bnoPrefix, pageNo: 1, numOfRows: 100 },
|
||||
serviceKey,
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
if (basic.kind === "auth-error") {
|
||||
return { error: "upstream_forbidden", message: `NPS upstream rejected the request (${basic.reason}). The proxy key may not be approved for service 3046071.` };
|
||||
}
|
||||
if (basic.kind === "error") {
|
||||
return { error: "upstream_error", message: basic.reason };
|
||||
}
|
||||
|
||||
// Defensive re-filter by the 6-digit prefix (trust upstream but verify).
|
||||
let candidates = basic.items;
|
||||
if (bnoPrefix) {
|
||||
candidates = candidates.filter((it) => digitsOnly(it.bzowrRgstNo).startsWith(bnoPrefix) || !it.bzowrRgstNo);
|
||||
}
|
||||
|
||||
// The same workplace repeats per dataCrtYm; keep the latest month per
|
||||
// (wkplNm + road address).
|
||||
const grouped = new Map();
|
||||
for (const it of candidates) {
|
||||
const key = `${(it.wkplNm || "").trim()}\u001f${(it.wkplRoadNmDtlAddr || "").trim()}`;
|
||||
const prev = grouped.get(key);
|
||||
if (!prev || (it.dataCrtYm || "") > (prev.dataCrtYm || "")) {
|
||||
grouped.set(key, it);
|
||||
}
|
||||
}
|
||||
const deduped = [...grouped.values()].sort((a, b) => (b.dataCrtYm || "").localeCompare(a.dataCrtYm || ""));
|
||||
|
||||
const exact = deduped.filter((it) => (it.wkplNm || "").trim() === wkplNm.trim());
|
||||
const chosen = deduped.length === 1 ? deduped[0] : (exact.length === 1 ? exact[0] : null);
|
||||
|
||||
let detail = null;
|
||||
let monthly = null;
|
||||
if (chosen && chosen.seq) {
|
||||
const detailResult = await callOperation(
|
||||
"getDetailInfoSearchV2",
|
||||
{ seq: chosen.seq, dataCrtYm: chosen.dataCrtYm || "" },
|
||||
serviceKey,
|
||||
fetchImpl
|
||||
);
|
||||
if (detailResult.kind === "items") {
|
||||
detail = detailResult.items.length ? detailResult.items : null;
|
||||
} else if (detailResult.kind === "auth-error") {
|
||||
return { error: "upstream_forbidden", message: `NPS detail lookup rejected the request (${detailResult.reason}). The proxy key may not be approved for service 3046071.` };
|
||||
} else {
|
||||
return { error: "upstream_error", message: `NPS detail lookup failed (${detailResult.reason}).` };
|
||||
}
|
||||
|
||||
const periodResult = await callOperation(
|
||||
"getPdAcctoSttusInfoSearchV2",
|
||||
{ seq: chosen.seq },
|
||||
serviceKey,
|
||||
fetchImpl
|
||||
);
|
||||
if (periodResult.kind === "items") {
|
||||
monthly = periodResult.items.length
|
||||
? [...periodResult.items].sort((a, b) => (a.dataCrtYm || "").localeCompare(b.dataCrtYm || ""))
|
||||
: null;
|
||||
} else if (periodResult.kind === "auth-error") {
|
||||
return { error: "upstream_forbidden", message: `NPS monthly status lookup rejected the request (${periodResult.reason}). The proxy key may not be approved for service 3046071.` };
|
||||
} else {
|
||||
return { error: "upstream_error", message: `NPS monthly status lookup failed (${periodResult.reason}).` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query: { wkplNm, bzowrRgstNo_prefix: bnoPrefix || null },
|
||||
candidate_count: deduped.length,
|
||||
candidates: deduped,
|
||||
raw_row_count: candidates.length,
|
||||
selected_candidate: chosen,
|
||||
detail,
|
||||
monthly_status: monthly,
|
||||
disclosure_note:
|
||||
"The business number is disclosed only to its first 6 digits (the rest is masked), so an exact-number match is impossible. Candidates matching name + 6-digit prefix are listed; when several match, identification is left to the caller."
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NPS_BASE_URL,
|
||||
normalizeNationalPensionQuery,
|
||||
parseNationalPensionXml,
|
||||
fetchNationalPensionWorkplace,
|
||||
};
|
||||
|
|
@ -42,14 +42,6 @@ const {
|
|||
const { fetchNearbyParkingLots } = require("./parking-lots");
|
||||
const { searchRegionCode } = require("./region-lookup");
|
||||
const { resolveEducationOfficeFromNaturalLanguage } = require("./neis-office-codes");
|
||||
const { normalizeNationalPensionQuery, fetchNationalPensionWorkplace } = require("./national-pension");
|
||||
const { normalizeFscCorpQuery, fetchFscCorpOutline } = require("./fsc-corp");
|
||||
const { normalizeG2bSanctionQuery, fetchG2bSanctions } = require("./g2b-sanction");
|
||||
const {
|
||||
normalizeKoreanLawDetailQuery,
|
||||
normalizeKoreanLawSearchQuery,
|
||||
proxyKoreanLawRequest
|
||||
} = require("./korean-law");
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const DATA_GO_KR_UPSTREAM_BASE_URL = "https://apis.data.go.kr";
|
||||
const DATA4LIBRARY_UPSTREAM_BASE_URL = "https://data4library.kr/api";
|
||||
|
|
@ -192,9 +184,6 @@ function buildConfig(env = process.env) {
|
|||
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
|
||||
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
|
||||
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
|
||||
lawOc: trimOrNull(env.LAW_OC),
|
||||
lawReferer: trimOrNull(env.LAW_REFERER),
|
||||
lawUserAgent: trimOrNull(env.LAW_USER_AGENT),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -1899,11 +1888,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
naverSearchApiConfigured: naverSearchKeysPresent,
|
||||
naverNewsApiConfigured: naverSearchKeysPresent,
|
||||
ntsBusinessConfigured: Boolean(config.molitApiKey),
|
||||
kstartupConfigured: Boolean(config.molitApiKey),
|
||||
nationalPensionConfigured: Boolean(config.molitApiKey),
|
||||
fscCorpConfigured: Boolean(config.molitApiKey),
|
||||
g2bSanctionConfigured: Boolean(config.molitApiKey),
|
||||
koreanLawConfigured: Boolean(config.lawOc)
|
||||
kstartupConfigured: Boolean(config.molitApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -3381,94 +3366,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
reply
|
||||
}));
|
||||
|
||||
// Shared handler for keyed data.go.kr GET lookups that reuse the operator's
|
||||
// DATA_GO_KR_API_KEY server-side (national pension, FSC corp, G2B sanctions).
|
||||
async function handleKeyedDataGoKrLookup({ route, normalizer, fetcher, request, reply }) {
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalizer(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return { error: "bad_request", message: error.message };
|
||||
}
|
||||
|
||||
if (!config.molitApiKey) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "upstream_not_configured",
|
||||
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
|
||||
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({ route, ...normalized });
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: { ...cached.proxy, cache: { hit: true, ttl_ms: config.cacheTtlMs } }
|
||||
};
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher({ ...normalized, serviceKey: config.molitApiKey });
|
||||
} catch (error) {
|
||||
reply.code(502);
|
||||
return {
|
||||
error: "proxy_error",
|
||||
message: error.message,
|
||||
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
|
||||
};
|
||||
}
|
||||
|
||||
const keyedErrorStatus = {
|
||||
upstream_forbidden: 502,
|
||||
upstream_timeout: 504,
|
||||
upstream_invalid_response: 502,
|
||||
upstream_error: 502
|
||||
};
|
||||
|
||||
if (result && result.error) {
|
||||
reply.code(keyedErrorStatus[result.error] || 502);
|
||||
return {
|
||||
...result,
|
||||
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...result,
|
||||
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
|
||||
};
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
app.get("/v1/national-pension/workplace", async (request, reply) => handleKeyedDataGoKrLookup({
|
||||
route: "national-pension-workplace",
|
||||
normalizer: normalizeNationalPensionQuery,
|
||||
fetcher: fetchNationalPensionWorkplace,
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/fsc/corp-outline", async (request, reply) => handleKeyedDataGoKrLookup({
|
||||
route: "fsc-corp-outline",
|
||||
normalizer: normalizeFscCorpQuery,
|
||||
fetcher: fetchFscCorpOutline,
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/g2b/sanctioned-supplier", async (request, reply) => handleKeyedDataGoKrLookup({
|
||||
route: "g2b-sanctioned-supplier",
|
||||
normalizer: normalizeG2bSanctionQuery,
|
||||
fetcher: fetchG2bSanctions,
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
async function handleKstartupRoute({ operation, route, request, reply }) {
|
||||
let normalized;
|
||||
try {
|
||||
|
|
@ -3618,97 +3515,6 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
reply
|
||||
}));
|
||||
|
||||
async function handleKoreanLawRoute({ endpoint, normalize, cacheRoute, request, reply }) {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalize(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 400);
|
||||
return {
|
||||
error: error.code || "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({
|
||||
route: cacheRoute,
|
||||
target: normalized.target,
|
||||
type: normalized.type,
|
||||
params: normalized.params
|
||||
});
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
if (typeof cached === "object" && cached.body !== undefined) {
|
||||
reply.code(cached.statusCode);
|
||||
reply.header("content-type", cached.contentType);
|
||||
return cached.body;
|
||||
}
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: { hit: true, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxyKoreanLawRequest({
|
||||
endpoint,
|
||||
normalized,
|
||||
oc: config.lawOc,
|
||||
userAgent: config.lawUserAgent,
|
||||
referer: config.lawReferer
|
||||
});
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
||||
if (!upstream.contentType.includes("json")) {
|
||||
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
|
||||
cache.set(
|
||||
cacheKey,
|
||||
{ statusCode: upstream.statusCode, contentType: upstream.contentType, body: upstream.body },
|
||||
config.cacheTtlMs
|
||||
);
|
||||
}
|
||||
return upstream.body;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(upstream.body);
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
payload.proxy = {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
requested_at: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
if (upstream.statusCode >= 200 && upstream.statusCode < 300) {
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
app.get("/v1/korean-law/search", async (request, reply) =>
|
||||
handleKoreanLawRoute({
|
||||
endpoint: "search",
|
||||
normalize: normalizeKoreanLawSearchQuery,
|
||||
cacheRoute: "korean-law-search",
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/korean-law/detail", async (request, reply) =>
|
||||
handleKoreanLawRoute({
|
||||
endpoint: "detail",
|
||||
normalize: normalizeKoreanLawDetailQuery,
|
||||
cacheRoute: "korean-law-detail",
|
||||
request,
|
||||
reply
|
||||
}));
|
||||
|
||||
app.get("/v1/mfds/drug-safety/lookup", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
DEFAULT_REFERER,
|
||||
DEFAULT_USER_AGENT,
|
||||
buildKoreanLawUrl,
|
||||
fetchKoreanLaw,
|
||||
isUserVerificationFailure,
|
||||
normalizeKoreanLawDetailQuery,
|
||||
normalizeKoreanLawSearchQuery,
|
||||
proxyKoreanLawRequest
|
||||
} = require("../src/korean-law");
|
||||
|
||||
const noopSleep = async () => {};
|
||||
|
||||
function jsonResponse(body, { status = 200, contentType = "application/json; charset=utf-8" } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => (name.toLowerCase() === "content-type" ? contentType : null) },
|
||||
text: async () => (typeof body === "string" ? body : JSON.stringify(body))
|
||||
};
|
||||
}
|
||||
|
||||
test("normalizeKoreanLawSearchQuery requires a target", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ query: "관세법" }), /target is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery rejects an unsupported target", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "evil", query: "x" }), /Unsupported target/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery requires a search query", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law" }), /search query is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawSearchQuery keeps only allowlisted params and defaults type to JSON", () => {
|
||||
const normalized = normalizeKoreanLawSearchQuery({
|
||||
target: "prec",
|
||||
query: "부당해고",
|
||||
display: "5",
|
||||
curt: "대법원",
|
||||
evil: "drop-me"
|
||||
});
|
||||
|
||||
assert.equal(normalized.target, "prec");
|
||||
assert.equal(normalized.type, "JSON");
|
||||
assert.deepEqual(normalized.params, { query: "부당해고", display: "5", curt: "대법원" });
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawDetailQuery requires an identifier", () => {
|
||||
assert.throws(() => normalizeKoreanLawDetailQuery({ target: "prec" }), /detail identifier is required/);
|
||||
});
|
||||
|
||||
test("normalizeKoreanLawDetailQuery accepts ID and passthrough params", () => {
|
||||
const normalized = normalizeKoreanLawDetailQuery({ target: "prec", ID: "228541", JO: "0002", evil: "x" });
|
||||
assert.deepEqual(normalized.params, { ID: "228541", JO: "0002" });
|
||||
});
|
||||
|
||||
test("normalizeType rejects unsupported types", () => {
|
||||
assert.throws(() => normalizeKoreanLawSearchQuery({ target: "law", query: "x", type: "csv" }), /Unsupported type/);
|
||||
});
|
||||
|
||||
test("buildKoreanLawUrl injects OC, target, type and routes search vs detail", () => {
|
||||
const searchUrl = buildKoreanLawUrl({
|
||||
endpoint: "search",
|
||||
target: "prec",
|
||||
type: "JSON",
|
||||
params: { query: "부당해고" },
|
||||
oc: "secret-oc"
|
||||
});
|
||||
assert.match(searchUrl, /\/DRF\/lawSearch\.do\?/);
|
||||
assert.match(searchUrl, /OC=secret-oc/);
|
||||
assert.match(searchUrl, /target=prec/);
|
||||
assert.match(searchUrl, /type=JSON/);
|
||||
assert.match(searchUrl, /query=%EB%B6%80%EB%8B%B9%ED%95%B4%EA%B3%A0/);
|
||||
|
||||
const detailUrl = buildKoreanLawUrl({
|
||||
endpoint: "detail",
|
||||
target: "prec",
|
||||
type: "JSON",
|
||||
params: { ID: "228541" },
|
||||
oc: "secret-oc"
|
||||
});
|
||||
assert.match(detailUrl, /\/DRF\/lawService\.do\?/);
|
||||
assert.match(detailUrl, /ID=228541/);
|
||||
});
|
||||
|
||||
test("isUserVerificationFailure detects the law.go.kr rejection body", () => {
|
||||
assert.equal(isUserVerificationFailure('{"result":"사용자 정보 검증에 실패하였습니다."}'), true);
|
||||
assert.equal(isUserVerificationFailure('{"PrecSearch":{}}'), false);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw sends browser User-Agent and Referer headers", async () => {
|
||||
let sentHeaders = null;
|
||||
const fetchImpl = async (_url, options) => {
|
||||
sentHeaders = options.headers;
|
||||
return jsonResponse({ PrecSearch: { prec: [] } });
|
||||
};
|
||||
|
||||
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
|
||||
|
||||
assert.equal(sentHeaders["User-Agent"], DEFAULT_USER_AGENT);
|
||||
assert.equal(sentHeaders.Referer, DEFAULT_REFERER);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw honors custom User-Agent and Referer overrides", async () => {
|
||||
let sentHeaders = null;
|
||||
const fetchImpl = async (_url, options) => {
|
||||
sentHeaders = options.headers;
|
||||
return jsonResponse({ ok: true });
|
||||
};
|
||||
|
||||
await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", {
|
||||
fetchImpl,
|
||||
sleep: noopSleep,
|
||||
userAgent: "custom-ua",
|
||||
referer: "https://example.test/"
|
||||
});
|
||||
|
||||
assert.equal(sentHeaders["User-Agent"], "custom-ua");
|
||||
assert.equal(sentHeaders.Referer, "https://example.test/");
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw retries empty/HTML responses then succeeds", async () => {
|
||||
let calls = 0;
|
||||
const fetchImpl = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return jsonResponse("", { contentType: "application/json" });
|
||||
}
|
||||
if (calls === 2) {
|
||||
return jsonResponse("<html><body>maintenance</body></html>", { contentType: "text/html" });
|
||||
}
|
||||
return jsonResponse({ LawSearch: { law: [{ id: "1" }] } });
|
||||
};
|
||||
|
||||
const result = await fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep });
|
||||
assert.equal(calls, 3);
|
||||
assert.match(result.body, /LawSearch/);
|
||||
});
|
||||
|
||||
test("fetchKoreanLaw throws after exhausting retries on persistent empty bodies", async () => {
|
||||
const fetchImpl = async () => jsonResponse("", { contentType: "application/json" });
|
||||
await assert.rejects(
|
||||
() => fetchKoreanLaw("https://www.law.go.kr/DRF/lawSearch.do", { fetchImpl, sleep: noopSleep }),
|
||||
/empty or HTML/
|
||||
);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest returns 503 when LAW_OC is not configured", async () => {
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: null,
|
||||
sleep: noopSleep
|
||||
});
|
||||
assert.equal(result.statusCode, 503);
|
||||
assert.match(result.body, /upstream_not_configured/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest passes the OC through to the upstream URL", async () => {
|
||||
let calledUrl = null;
|
||||
const fetchImpl = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return jsonResponse({ LawSearch: { law: [] } });
|
||||
};
|
||||
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.match(calledUrl, /OC=secret-oc/);
|
||||
assert.match(calledUrl, /\/lawSearch\.do\?/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest maps a user-verification body to a 502 error", async () => {
|
||||
const fetchImpl = async () => jsonResponse({ result: "사용자 정보 검증에 실패하였습니다." });
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "search",
|
||||
normalized: { target: "law", type: "JSON", params: { query: "관세법" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 502);
|
||||
assert.match(result.body, /law_user_verification_failed/);
|
||||
});
|
||||
|
||||
test("proxyKoreanLawRequest surfaces upstream non-2xx responses verbatim", async () => {
|
||||
const fetchImpl = async () => jsonResponse("server error", { status: 500, contentType: "text/plain" });
|
||||
const result = await proxyKoreanLawRequest({
|
||||
endpoint: "detail",
|
||||
normalized: { target: "prec", type: "JSON", params: { ID: "228541" } },
|
||||
oc: "secret-oc",
|
||||
fetchImpl,
|
||||
sleep: noopSleep
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 500);
|
||||
});
|
||||
|
|
@ -5669,405 +5669,3 @@ test("K-Startup integer fields reject non-numeric input before upstream call", a
|
|||
}
|
||||
assert.equal(called, false, "upstream must not be called for any invalid integer input");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business due-diligence keyed routes: national pension, FSC corp, G2B sanction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
normalizeNationalPensionQuery,
|
||||
parseNationalPensionXml
|
||||
} = require("../src/national-pension");
|
||||
const { normalizeFscCorpQuery } = require("../src/fsc-corp");
|
||||
const { normalizeG2bSanctionQuery, extractSanctionItems } = require("../src/g2b-sanction");
|
||||
|
||||
function npsItemsXml(items) {
|
||||
const body = items
|
||||
.map(
|
||||
(it) =>
|
||||
"<item>" +
|
||||
Object.entries(it)
|
||||
.map(([k, v]) => `<${k}>${v}</${k}>`)
|
||||
.join("") +
|
||||
"</item>"
|
||||
)
|
||||
.join("");
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><response><header><resultCode>00</resultCode>' +
|
||||
`<resultMsg>NORMAL SERVICE.</resultMsg></header><body><items>${body}</items>` +
|
||||
`<totalCount>${items.length}</totalCount></body></response>`
|
||||
);
|
||||
}
|
||||
|
||||
test("national-pension normalizer requires a workplace name and derives the 6-digit prefix", () => {
|
||||
assert.throws(() => normalizeNationalPensionQuery({}), /wkplNm/);
|
||||
assert.deepEqual(normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123-45-67890" }), {
|
||||
wkplNm: "테스트상사",
|
||||
bnoPrefix: "123456"
|
||||
});
|
||||
assert.throws(() => normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123" }), /10-digit/);
|
||||
});
|
||||
|
||||
test("parseNationalPensionXml classifies gateway auth errors and item lists", () => {
|
||||
const auth = parseNationalPensionXml(
|
||||
"<OpenAPI_ServiceResponse><cmmMsgHeader><returnAuthMsg>SERVICE_KEY_IS_NOT_REGISTERED_ERROR</returnAuthMsg><returnReasonCode>30</returnReasonCode></cmmMsgHeader></OpenAPI_ServiceResponse>"
|
||||
);
|
||||
assert.equal(auth.kind, "auth-error");
|
||||
const ok = parseNationalPensionXml(npsItemsXml([{ wkplNm: "갑", seq: "1" }]));
|
||||
assert.equal(ok.kind, "items");
|
||||
assert.equal(ok.items[0].wkplNm, "갑");
|
||||
});
|
||||
|
||||
test("national-pension route orchestrates basic+detail+monthly and keeps the key server-side", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
if (u.includes("getBassInfoSearchV2")) {
|
||||
return new Response(
|
||||
npsItemsXml([
|
||||
{
|
||||
wkplNm: "테스트상사",
|
||||
bzowrRgstNo: "123456****",
|
||||
seq: "777",
|
||||
dataCrtYm: "202605",
|
||||
wkplRoadNmDtlAddr: "서울"
|
||||
},
|
||||
{
|
||||
wkplNm: "테스트상사",
|
||||
bzowrRgstNo: "123456****",
|
||||
seq: "777",
|
||||
dataCrtYm: "202604",
|
||||
wkplRoadNmDtlAddr: "서울"
|
||||
}
|
||||
]),
|
||||
{ status: 200, headers: { "content-type": "application/xml" } }
|
||||
);
|
||||
}
|
||||
if (u.includes("getDetailInfoSearchV2")) {
|
||||
return new Response(npsItemsXml([{ jnngpCnt: "120", crrmmNtcAmt: "5000000" }]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/xml" }
|
||||
});
|
||||
}
|
||||
if (u.includes("getPdAcctoSttusInfoSearchV2")) {
|
||||
return new Response(npsItemsXml([{ dataCrtYm: "202604" }, { dataCrtYm: "202605" }]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/xml" }
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected NPS URL: ${u}`);
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/national-pension/workplace?name=" + encodeURIComponent("테스트상사") + "&b_no=1234567890"
|
||||
});
|
||||
const body = res.json();
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(body.candidate_count, 1, "month-duplicated rows collapse to one workplace");
|
||||
assert.equal(body.raw_row_count, 2);
|
||||
assert.equal(body.selected_candidate.seq, "777");
|
||||
assert.equal(body.detail[0].jnngpCnt, "120");
|
||||
assert.equal(body.monthly_status[0].dataCrtYm, "202604");
|
||||
assert.equal(body.proxy.cache.hit, false);
|
||||
assert.deepEqual(calls.map((u) => new URL(u).pathname.split("/").pop()), [
|
||||
"getBassInfoSearchV2",
|
||||
"getDetailInfoSearchV2",
|
||||
"getPdAcctoSttusInfoSearchV2"
|
||||
]);
|
||||
assert.ok(calls.every((u) => new URL(u).searchParams.get("serviceKey") === "data-go-key"));
|
||||
assert.equal(JSON.stringify(body).includes("data-go-key"), false, "service key must not leak into the response");
|
||||
|
||||
const cached = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/national-pension/workplace?name=" + encodeURIComponent("테스트상사") + "&b_no=1234567890"
|
||||
});
|
||||
assert.equal(cached.json().proxy.cache.hit, true);
|
||||
});
|
||||
|
||||
test("national-pension route reports missing key and rejects nameless queries", async (t) => {
|
||||
const app = buildServer();
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const noKey = await app.inject({ method: "GET", url: "/v1/national-pension/workplace?name=갑" });
|
||||
assert.equal(noKey.statusCode, 503);
|
||||
assert.equal(noKey.json().error, "upstream_not_configured");
|
||||
|
||||
const keyedApp = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => {
|
||||
await keyedApp.close();
|
||||
});
|
||||
const bad = await keyedApp.inject({ method: "GET", url: "/v1/national-pension/workplace" });
|
||||
assert.equal(bad.statusCode, 400);
|
||||
assert.equal(bad.json().error, "bad_request");
|
||||
});
|
||||
|
||||
test("fsc corp normalizer requires a corporate name", () => {
|
||||
assert.throws(() => normalizeFscCorpQuery({}), /corpNm/);
|
||||
assert.deepEqual(normalizeFscCorpQuery({ name: "테스트", b_no: "123-45-67890" }), {
|
||||
corpNm: "테스트",
|
||||
bno: "1234567890"
|
||||
});
|
||||
assert.throws(() => normalizeFscCorpQuery({ name: "테스트", b_no: "123" }), /10-digit/);
|
||||
});
|
||||
|
||||
test("fsc corp-outline route returns name-matched candidates and cross-checks bzno when present", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async (url) => {
|
||||
fetchCalls += 1;
|
||||
assert.match(String(url), /corpNm=/);
|
||||
assert.match(String(url), /serviceKey=data-go-key/);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode: "00", resultMsg: "NORMAL SERVICE." },
|
||||
body: { items: { item: [{ corpNm: "테스트", crno: "1101111111111", bzno: "1234567890" }] } }
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트") + "&b_no=1234567890"
|
||||
});
|
||||
const body = res.json();
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(body.candidate_count, 1);
|
||||
assert.equal(body.b_no_cross_check.checked, true);
|
||||
assert.equal(body.b_no_cross_check.matched_candidates.length, 1);
|
||||
const cached = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트") + "&b_no=1234567890"
|
||||
});
|
||||
assert.equal(cached.json().proxy.cache.hit, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
});
|
||||
|
||||
test("fsc corp-outline route maps upstream 403 to a 502 forbidden error", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => new Response("Forbidden", { status: 403 });
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트")
|
||||
});
|
||||
assert.equal(res.statusCode, 502);
|
||||
assert.equal(res.json().error, "upstream_forbidden");
|
||||
});
|
||||
|
||||
test("g2b sanction normalizer enforces a 10-digit business number", () => {
|
||||
assert.throws(() => normalizeG2bSanctionQuery({ bizno: "123" }), /10-digit/);
|
||||
assert.deepEqual(normalizeG2bSanctionQuery({ b_no: "123-45-67890" }), { bizno: "1234567890" });
|
||||
});
|
||||
|
||||
test("g2b extractSanctionItems tolerates dict and single-item variants", () => {
|
||||
assert.deepEqual(
|
||||
extractSanctionItems({ response: { header: { resultCode: "00" }, body: { items: "", totalCount: 0 } } }).items,
|
||||
[]
|
||||
);
|
||||
const single = extractSanctionItems({
|
||||
response: { header: { resultCode: "00" }, body: { items: { item: { bizNm: "갑" } }, totalCount: 1 } }
|
||||
});
|
||||
assert.equal(single.items.length, 1);
|
||||
});
|
||||
|
||||
test("g2b sanctioned-supplier route returns active sanctions and uses capital-S ServiceKey", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const seenUrls = [];
|
||||
global.fetch = async (url) => {
|
||||
seenUrls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode: "00" },
|
||||
body: { items: { item: [{ bizno: "1234567890", bizNm: "갑", rstrtSttDt: "20250101" }] }, totalCount: 1 }
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
const res = await app.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
|
||||
const body = res.json();
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(body.total_count, 1);
|
||||
assert.equal(body.active_sanctions[0].bizNm, "갑");
|
||||
assert.match(seenUrls[0], /ServiceKey=data-go-key/);
|
||||
assert.match(seenUrls[0], /inqryDiv=1/);
|
||||
|
||||
const cached = await app.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
|
||||
assert.equal(cached.json().proxy.cache.hit, true);
|
||||
assert.equal(seenUrls.length, 1);
|
||||
|
||||
const noKey = buildServer();
|
||||
t.after(async () => {
|
||||
await noKey.close();
|
||||
});
|
||||
const missing = await noKey.inject({ method: "GET", url: "/v1/g2b/sanctioned-supplier?bizno=1234567890" });
|
||||
assert.equal(missing.statusCode, 503);
|
||||
|
||||
});
|
||||
|
||||
test("korean-law search endpoint proxies law.go.kr with the server OC and browser headers", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl = null;
|
||||
let calledHeaders = null;
|
||||
global.fetch = async (url, options) => {
|
||||
calledUrl = String(url);
|
||||
calledHeaders = options.headers;
|
||||
return new Response(JSON.stringify({ PrecSearch: { prec: [{ 사건번호: "2023두54914" }] } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-law/search?target=prec&query=%EB%B6%80%EB%8B%B9%ED%95%B4%EA%B3%A0"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().PrecSearch.prec[0].사건번호, "2023두54914");
|
||||
assert.equal(response.json().proxy.cache.hit, false);
|
||||
assert.match(calledUrl, /\/DRF\/lawSearch\.do\?/);
|
||||
assert.match(calledUrl, /OC=server-oc/);
|
||||
assert.match(calledUrl, /target=prec/);
|
||||
assert.ok(calledHeaders["User-Agent"].includes("Mozilla/5.0"));
|
||||
assert.equal(calledHeaders.Referer, "https://www.law.go.kr/");
|
||||
});
|
||||
|
||||
test("korean-law search endpoint caches successful upstream responses", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let fetchCalls = 0;
|
||||
global.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response(JSON.stringify({ LawSearch: { law: [] } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const url = "/v1/korean-law/search?target=law&query=%EA%B4%80%EC%84%B8%EB%B2%95";
|
||||
const first = await app.inject({ method: "GET", url });
|
||||
const second = await app.inject({ method: "GET", url });
|
||||
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
assert.equal(fetchCalls, 1);
|
||||
});
|
||||
|
||||
test("korean-law detail endpoint routes to lawService.do", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl = null;
|
||||
global.fetch = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response(JSON.stringify({ PrecService: { 판례정보일련번호: "228541" } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-law/detail?target=prec&ID=228541"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.match(calledUrl, /\/DRF\/lawService\.do\?/);
|
||||
assert.match(calledUrl, /ID=228541/);
|
||||
});
|
||||
|
||||
test("korean-law search endpoint returns 400 for a missing query", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let called = false;
|
||||
global.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("{}", { status: 200 });
|
||||
};
|
||||
|
||||
const app = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/v1/korean-law/search?target=law" });
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("korean-law search endpoint returns 503 when the proxy server lacks LAW_OC", async (t) => {
|
||||
const app = buildServer();
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/korean-law/search?target=law&query=%EA%B4%80%EC%84%B8%EB%B2%95"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("health endpoint reports koreanLawConfigured from LAW_OC", async (t) => {
|
||||
const off = buildServer();
|
||||
const on = buildServer({ env: { LAW_OC: "server-oc" } });
|
||||
t.after(async () => {
|
||||
await off.close();
|
||||
await on.close();
|
||||
});
|
||||
|
||||
const offBody = (await off.inject({ method: "GET", url: "/health" })).json();
|
||||
const onBody = (await on.inject({ method: "GET", url: "/health" })).json();
|
||||
|
||||
assert.equal(offBody.upstreams.koreanLawConfigured, false);
|
||||
assert.equal(onBody.upstreams.koreanLawConfigured, true);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
# toss-securities
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 66f12cb: Add an official Toss Securities Open API client alongside the existing unofficial `tossctl` wrapper. The package now ships read-only helpers backed by the official REST API (`https://openapi.tossinvest.com`): OAuth 2.0 Client Credentials token issuance with an in-memory token cache, bearer + `X-Tossinvest-Account` header handling, `TossApiError`/`TossCredentialsError` envelopes with secret/token redaction, and 429 `Retry-After`/backoff retry. New read-only helpers cover prices, orderbook, trades, price limits, candles, stocks, stock warnings, exchange rate, market calendars, accounts, holdings, open orders, order detail, buying power, sellable quantity, and commissions. Credentials are read from `TOSSINVEST_CLIENT_ID`/`TOSSINVEST_CLIENT_SECRET` (optional `TOSSINVEST_ACCOUNT`/`TOSSINVEST_API_BASE_URL`) and sent directly to Toss, never through a shared proxy. Order mutation (create/modify/cancel) remains out of scope. The `tossctl` path is retained as a documented fallback.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "toss-securities",
|
||||
"version": "0.5.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Read-only Toss Securities client: official Open API (OAuth2) first, unofficial tossctl wrapper as fallback",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
|
|
|
|||
|
|
@ -1,131 +0,0 @@
|
|||
---
|
||||
name: saramin-talent-search
|
||||
description: 사람인 기업회원 인재풀 로그인 세션에서 마스킹된 후보 정보를 검색·비교해 유료 열람 전 shortlist를 만듭니다.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: recruiting
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# saramin-talent-search
|
||||
|
||||
사람인 인재풀에서 유료 열람/연락처 확인/제안 발송 전에 현재 보이는 마스킹 후보 정보를 비교해 “열람할 만한 후보”를 추천한다. 개발자 전용이 아니며 모든 직무에 role-adaptive하게 적용한다.
|
||||
|
||||
## Use when
|
||||
|
||||
- 사용자가 사람인 인재풀에서 후보를 찾아달라고 요청한다.
|
||||
- 기업회원 로그인/2차 인증이 완료된 브라우저 세션에서 후보를 검색해야 한다.
|
||||
- 유료 열람 전 shortlist, 점수, 근거, 리스크, 후보 URL이 필요하다.
|
||||
|
||||
## Hard boundaries
|
||||
|
||||
Allowed:
|
||||
- 사람인 인재풀 브라우저 세션 열기 및 검색 필터 입력
|
||||
- 현재 보이는 마스킹 후보 목록/프로필/이력서 읽기
|
||||
- 후보 분석, 점수화, shortlist 작성, 유료 열람 추천
|
||||
|
||||
Never do without explicit user handoff/confirmation:
|
||||
- 유료 이력서 열람, 연락처 확인, 마스킹 해제
|
||||
- 포지션/입사 제안 발송
|
||||
- 스크랩, 관심후보 등록, 메모, 후보 상태 변경
|
||||
- 결제/유료 상품 사용
|
||||
- 비밀번호, OTP, 인증번호, 세션 쿠키 요청/저장
|
||||
- 후보 개인정보 장기 저장 또는 대량 수집
|
||||
|
||||
If a control may spend credits, reveal contact/private info, send a proposal, or mutate account state, stop before clicking it.
|
||||
|
||||
## Primary access
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
https://www.saramin.co.kr/zf_user/memcom/talent-pool/main/search
|
||||
```
|
||||
|
||||
If login or first-device verification is required, pause and show:
|
||||
|
||||
```text
|
||||
사람인 인재풀 검색은 기업회원 로그인과, 처음 사용하는 브라우저/기기에서는 2차 인증이 필요할 수 있습니다.
|
||||
제가 브라우저로 사람인 인재풀 검색 페이지를 열어둘게요.
|
||||
열린 브라우저에서 직접 로그인과 필요한 경우 2차 인증을 완료해 주세요.
|
||||
비밀번호, 인증번호, 세션 쿠키는 저에게 알려주지 마세요.
|
||||
인재풀 검색 화면이 보이면 “인증 완료했어”라고 알려주세요.
|
||||
그 다음 같은 브라우저 세션에서 검색을 이어가겠습니다.
|
||||
```
|
||||
|
||||
Resume only in the same browser session after the user confirms the search UI is visible.
|
||||
|
||||
## Input normalization
|
||||
|
||||
Extract or infer:
|
||||
|
||||
- role_title
|
||||
- must_have / nice_to_have
|
||||
- negative_keywords
|
||||
- career min/max
|
||||
- location/work_area
|
||||
- role-specific evaluation signals
|
||||
- limit / requested Top N
|
||||
|
||||
Do not block on missing details when a reasonable first search is possible.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Open the primary URL and verify corporate login plus search UI visibility.
|
||||
2. Ask the user to log in/verify manually only when required; never handle credentials.
|
||||
3. Apply filters: keyword, 직무/직종, 경력, 지역, recent update/activity/relevance sorting, exclusions when supported.
|
||||
4. Build a candidate pool from visible rows.
|
||||
5. Before final Top N, open normal profile/resume detail links for promising candidates when this does not trigger paid unlock/contact/proposal actions.
|
||||
6. Read only visible free/masked details: career, responsibilities, project/achievement evidence, skills, education/certs/languages, desired location/salary/job-seeking state, portfolio links if visible, recent activity.
|
||||
7. Score role-adaptively: must-have fit, career depth, concrete achievement/project evidence, location/activity fit, nice-to-have signals, and risk penalty.
|
||||
8. Return Korean shortlist with direct URL per recommended candidate.
|
||||
|
||||
Do not finalize Top N from list rows only unless details are inaccessible or paid-walled. If so, label results as `목록 기반 1차 shortlist` and lower confidence. If detail text was inspected, label as `상세 이력 확인 기반 shortlist`.
|
||||
|
||||
## Permission guidance
|
||||
|
||||
Safe after normal tool/browser approval: opening the search page, typing filters, pressing search/apply, scrolling results, opening normal candidate detail links, reading currently visible masked/free text.
|
||||
|
||||
Must stop/handoff: paid unlock, contact reveal, proposal/send, scrap/interest, memo/status changes, payment, credential/OTP/cookie handling.
|
||||
|
||||
## URL extraction guidance
|
||||
|
||||
Every recommended candidate needs a direct Saramin profile/resume URL whenever available. If browser extraction fails, inspect anchors, onclick handlers, data attributes, card containers, and detail-page `location.href`. If still missing, write `URL: 추출 실패` and explain why.
|
||||
|
||||
## Output shape
|
||||
|
||||
```text
|
||||
사람인 인재풀 shortlist
|
||||
|
||||
검색 조건
|
||||
- 포지션: ...
|
||||
- 필수/우대/제외 조건: ...
|
||||
- 경력/지역: ...
|
||||
- 모드: 상세 이력 확인 기반 shortlist / 목록 기반 1차 shortlist
|
||||
|
||||
유료 열람 추천 Top N
|
||||
1. 후보 A
|
||||
- 점수: 88/100
|
||||
- 근거: ...
|
||||
- 보이는 경력/성과: ...
|
||||
- 리스크: ...
|
||||
- 추천 액션: 채용 담당자가 유료 열람 검토
|
||||
- URL: ...
|
||||
|
||||
보류 후보
|
||||
- ...
|
||||
|
||||
검색 한계
|
||||
- 마스킹/현재 표시 정보만 분석했음
|
||||
- 연락처/실명/비공개 정보는 열람하지 않음
|
||||
- 유료 액션은 실행하지 않음
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Login/2FA required: open the page and let the user complete it manually.
|
||||
- Browser/session unavailable: explain that the agent needs browser/computer-use access; do not silently switch to no-login scraping.
|
||||
- Paid wall/contact wall: stop and mark as manual paid review needed.
|
||||
- Empty results: adjust keywords, career, region, update/relevance filters.
|
||||
- UI changed: rediscover the visible form/data flow before updating instructions.
|
||||
11
scripts/kakaotalk_mac.py
Normal file
11
scripts/kakaotalk_mac.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BUNDLED_HELPER = Path(__file__).resolve().parent.parent / "kakaotalk-mac" / "scripts" / "kakaotalk_mac.py"
|
||||
|
||||
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
|
||||
raise FileNotFoundError(f"Bundled KakaoTalk helper not found: {_BUNDLED_HELPER}")
|
||||
|
||||
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())
|
||||
|
|
@ -311,8 +311,8 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
|
|||
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
|
||||
assert.match(readme, /\| 카카오톡 Mac 아카이브 검색 \|/);
|
||||
assert.match(readme, /\[카카오톡 Mac 아카이브 검색\]\(docs\/features\/kakaotalk-mac\.md\)/);
|
||||
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
|
||||
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
|
||||
assert.match(install, /--skill kakaotalk-mac/);
|
||||
});
|
||||
|
||||
|
|
@ -634,42 +634,34 @@ test("hosted proxy docs keep self-host overrides inactive and demonstrate resolv
|
|||
}
|
||||
});
|
||||
|
||||
test("kakaotalk-mac skill documents katok archive search usage", () => {
|
||||
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
||||
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
|
||||
|
||||
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
|
||||
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
|
||||
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
|
||||
|
||||
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
|
||||
|
||||
assert.match(skill, /^name: kakaotalk-mac$/m);
|
||||
assert.match(skill, /kakaocli/);
|
||||
assert.match(skill, /macOS/i);
|
||||
assert.match(skill, /KakaoTalk/i);
|
||||
assert.match(skill, /Full Disk Access/i);
|
||||
assert.match(skill, /Accessibility/i);
|
||||
assert.match(skill, /--me/);
|
||||
assert.match(skill, /confirm before sending/i);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /\bkatok\b/);
|
||||
assert.match(doc, /macOS/i);
|
||||
assert.match(doc, /KakaoTalk/i);
|
||||
assert.match(doc, /Full Disk Access/i);
|
||||
assert.match(doc, /katok doctor --json/);
|
||||
assert.match(doc, /katok permissions macos/);
|
||||
assert.match(doc, /katok sync --source macos --json/);
|
||||
assert.match(doc, /katok index --json/);
|
||||
assert.match(doc, /katok search keyword/);
|
||||
assert.match(doc, /katok search bm25/);
|
||||
assert.match(doc, /katok search semantic/);
|
||||
assert.match(doc, /katok chunk get/);
|
||||
assert.match(doc, /katok chunk context/);
|
||||
assert.match(doc, /katok chunk parent/);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}((direct|raw).{0,40}(DB|database).{0,40}read|directly read.{0,40}(DB|database))/i);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}(auth|authentication).{0,40}caches?/i);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}decryption material/i);
|
||||
assert.doesNotMatch(doc, /kakaocli/);
|
||||
assert.doesNotMatch(doc, /python3 scripts\/kakaotalk_mac\.py/);
|
||||
assert.doesNotMatch(doc, /send --me/);
|
||||
assert.doesNotMatch(doc, /delete-last/);
|
||||
assert.doesNotMatch(doc, /confirm before sending/i);
|
||||
assert.doesNotMatch(doc, /SQLCipher key/i);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
|
||||
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
|
||||
assert.match(doc, /cache|캐시/);
|
||||
assert.match(doc, /read-only|읽기 전용/i);
|
||||
assert.doesNotMatch(doc, /`query`/);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -2016,7 +2008,7 @@ test("package-lock captures the toss-securities workspace metadata for npm ci",
|
|||
assert.equal(packageLock.packages["packages/toss-securities"].engines.node, ">=18");
|
||||
});
|
||||
|
||||
test("repository docs advertise the korean-law-search skill via k-skill-proxy", () => {
|
||||
test("repository docs advertise the korean-law-search skill with mode-specific korean-law-mcp setup guidance", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
|
|
@ -2032,30 +2024,26 @@ test("repository docs advertise the korean-law-search skill via k-skill-proxy",
|
|||
assert.match(readme, /\[한국 법령 검색 가이드\]\(docs\/features\/korean-law-search\.md\)/);
|
||||
assert.match(readme, /\| 한국 법령 검색 \| .* \| 불필요 \|/);
|
||||
assert.match(install, /--skill korean-law-search/);
|
||||
assert.match(install, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(install, /운영자만 proxy 서버에 `LAW_OC`/);
|
||||
assert.match(setup, /한국 법령 검색은 기본 hosted proxy/);
|
||||
assert.match(setup, /self-host proxy 운영자만 서버 환경변수 `LAW_OC`/);
|
||||
assert.match(featureDoc, /\/v1\/korean-law\/search/);
|
||||
assert.match(featureDoc, /\/v1\/korean-law\/detail/);
|
||||
assert.match(setupSkill, /한국 법령 검색은 기본 hosted proxy/);
|
||||
assert.match(setupSkill, /운영자만 서버 환경변수 `LAW_OC`/);
|
||||
assert.match(install, /로컬 CLI\/MCP 경로는 `LAW_OC`/);
|
||||
assert.match(install, /remote endpoint는 `LAW_OC` 없이 `url`만/);
|
||||
assert.match(setup, /한국 법령 검색의 로컬 CLI\/MCP 경로용 `LAW_OC`/);
|
||||
assert.match(setup, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(featureDoc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(featureDoc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(setupSkill, /로컬 한국 법령 검색: `LAW_OC` \+ `korean-law-mcp`/);
|
||||
assert.match(setupSkill, /remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록/);
|
||||
|
||||
for (const doc of [setup, security, setupSkill]) {
|
||||
assert.match(doc, /LAW_OC/);
|
||||
assert.match(doc, /k-skill-proxy/);
|
||||
assert.match(doc, /korean-law-mcp/);
|
||||
}
|
||||
|
||||
assert.match(sources, /korean-law-mcp: https:\/\/github\.com\/chrisryugj\/korean-law-mcp/);
|
||||
assert.match(sources, /beopmang: https:\/\/api\.beopmang\.org/);
|
||||
assert.match(roadmap, /한국 법령 검색 스킬 출시/);
|
||||
|
||||
for (const doc of [readme, install, setup, security, setupSkill, sources, featureDoc]) {
|
||||
assert.doesNotMatch(doc, /법망|beopmang/i);
|
||||
assert.doesNotMatch(doc, /api\.beopmang\.org/);
|
||||
}
|
||||
});
|
||||
|
||||
test("korean-law-search skill is proxy-first and drops the Beopmang fallback", () => {
|
||||
test("korean-law-search skill keeps korean-law-mcp-first guidance while documenting the approved Beopmang fallback", () => {
|
||||
const skillPath = path.join(repoRoot, "korean-law-search", "SKILL.md");
|
||||
const featureDoc = read(path.join("docs", "features", "korean-law-search.md"));
|
||||
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
|
||||
|
|
@ -2072,24 +2060,30 @@ test("korean-law-search skill is proxy-first and drops the Beopmang fallback", (
|
|||
const doneSection = doneSectionMatch[1];
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /k-skill-proxy\.nomadamas\.org/);
|
||||
assert.match(doc, /\/v1\/korean-law\/search/);
|
||||
assert.match(doc, /\/v1\/korean-law\/detail/);
|
||||
assert.match(doc, /target=law/);
|
||||
assert.match(doc, /target=prec/);
|
||||
assert.match(doc, /korean-law-mcp.*먼저|먼저.*korean-law-mcp|항상 `korean-law-mcp`를 먼저 사용/u);
|
||||
assert.match(doc, /npm install -g korean-law-mcp/);
|
||||
assert.match(doc, /로컬 CLI 또는 로컬 MCP server 경로는 `LAW_OC`/);
|
||||
assert.match(doc, /remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결/);
|
||||
assert.match(doc, /open\.law\.go\.kr/);
|
||||
assert.match(doc, /github\.com\/chrisryugj\/korean-law-mcp/);
|
||||
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(doc, /LAW_OC/);
|
||||
assert.doesNotMatch(doc, /법망|beopmang/i);
|
||||
assert.doesNotMatch(doc, /api\.beopmang\.org/);
|
||||
assert.doesNotMatch(doc, /npm install -g korean-law-mcp/);
|
||||
assert.match(doc, /search_law/);
|
||||
assert.match(doc, /get_law_text/);
|
||||
assert.match(doc, /search_precedents/);
|
||||
assert.match(doc, /search_interpretations/);
|
||||
assert.match(doc, /search_ordinance/);
|
||||
assert.match(doc, /https:\/\/korean-law-mcp\.fly\.dev\/mcp/);
|
||||
assert.match(doc, /법망|Beopmang/i);
|
||||
assert.match(doc, /https:\/\/api\.beopmang\.org/);
|
||||
assert.match(doc, /fallback/i);
|
||||
assert.match(doc, /MCP/i);
|
||||
assert.match(doc, /CLI/i);
|
||||
assert.doesNotMatch(doc, /packages\/korean-law-search/);
|
||||
assert.doesNotMatch(doc, /python-packages\/korean-law-search/);
|
||||
}
|
||||
|
||||
assert.match(doneSection, /target=prec/);
|
||||
assert.match(doneSection, /target=ordin/);
|
||||
assert.match(doneSection, /search_interpretations/);
|
||||
assert.match(doneSection, /search_ordinance/);
|
||||
assert.match(doneSection, /법망|Beopmang/i);
|
||||
assert.match(doneSection, /fallback/i);
|
||||
|
||||
assert.doesNotMatch(
|
||||
featureDoc,
|
||||
|
|
@ -4000,7 +3994,7 @@ test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver chang
|
|||
const README_SKILL_NAME_COLUMN_MAPPING = [
|
||||
["SRT 예매", "srt-booking"],
|
||||
["KTX 예매", "ktx-booking"],
|
||||
["카카오톡 Mac 아카이브 검색", "kakaotalk-mac"],
|
||||
["카카오톡 Mac CLI", "kakaotalk-mac"],
|
||||
["서울 지하철 도착정보 조회", "seoul-subway-arrival"],
|
||||
["지하철 분실물 조회", "subway-lost-property"],
|
||||
["긱뉴스 조회", "geeknews-search"],
|
||||
|
|
|
|||
473
scripts/test_kakaotalk_mac.py
Normal file
473
scripts/test_kakaotalk_mac.py
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import scripts.kakaotalk_mac as kakaotalk_mac
|
||||
|
||||
|
||||
def sha512_hex(value: int) -> str:
|
||||
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def make_resolved_auth(
|
||||
*,
|
||||
user_id: int = 123,
|
||||
uuid: str = "uuid",
|
||||
database_path: Path | None = None,
|
||||
database_name: str = "db-name",
|
||||
key: str = "super-secret",
|
||||
source: str = "cache",
|
||||
) -> kakaotalk_mac.ResolvedAuth:
|
||||
return kakaotalk_mac.ResolvedAuth(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
database_path=database_path or Path("/tmp/kakaotalk.db"),
|
||||
database_name=database_name,
|
||||
key=key,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
class KakaoTalkMacHelperTests(unittest.TestCase):
|
||||
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
|
||||
active_hash = sha512_hex(123456)
|
||||
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AlertKakaoIDsList</key>
|
||||
<array>
|
||||
<integer>111</integer>
|
||||
<integer>222</integer>
|
||||
</array>
|
||||
<key>userId</key>
|
||||
<integer>333</integer>
|
||||
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
|
||||
|
||||
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
|
||||
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
|
||||
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
|
||||
|
||||
def test_discover_database_files_filters_hex_names(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
root = Path(tempdir)
|
||||
expected = [
|
||||
root / ("a" * 78),
|
||||
root / ("b" * 78 + ".db"),
|
||||
]
|
||||
for path in expected:
|
||||
path.write_text("", encoding="utf-8")
|
||||
(root / ("c" * 40)).write_text("", encoding="utf-8")
|
||||
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
|
||||
|
||||
discovered = kakaotalk_mac.discover_database_files(root)
|
||||
|
||||
self.assertEqual(discovered, expected)
|
||||
|
||||
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
|
||||
target_user_id = 123456
|
||||
recovered = kakaotalk_mac.recover_user_id_from_sha512(
|
||||
sha512_hex(target_user_id),
|
||||
max_user_id=200000,
|
||||
workers=1,
|
||||
chunk_size=5000,
|
||||
)
|
||||
|
||||
self.assertEqual(recovered, target_user_id)
|
||||
|
||||
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
|
||||
target_user_id = 654321
|
||||
active_hash = sha512_hex(target_user_id)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
verification_calls: list[int] = []
|
||||
|
||||
state = kakaotalk_mac.DetectionState(
|
||||
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
|
||||
candidate_user_ids=[111, 222],
|
||||
active_account_hash=active_hash,
|
||||
database_files=[database_path],
|
||||
)
|
||||
|
||||
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
|
||||
verification_calls.append(candidate.user_id)
|
||||
return candidate.user_id == target_user_id
|
||||
|
||||
resolved = kakaotalk_mac.resolve_auth_state(
|
||||
state,
|
||||
verify_access=verify,
|
||||
cache_path=cache_path,
|
||||
max_user_id=700000,
|
||||
workers=1,
|
||||
chunk_size=10000,
|
||||
)
|
||||
|
||||
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(verification_calls, [111, 222, target_user_id])
|
||||
self.assertEqual(resolved.user_id, target_user_id)
|
||||
self.assertEqual(resolved.database_path, database_path)
|
||||
self.assertEqual(cache_payload["user_id"], target_user_id)
|
||||
self.assertEqual(cache_payload["database_path"], str(database_path))
|
||||
|
||||
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
|
||||
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
|
||||
|
||||
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
|
||||
):
|
||||
cached = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(cached, resolved)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once()
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override="override-uuid",
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with("override-uuid")
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_render_auth_text_redacts_key_material(self) -> None:
|
||||
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
|
||||
|
||||
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
|
||||
|
||||
self.assertNotIn("super-secret-key", rendered)
|
||||
self.assertNotIn("--key", rendered)
|
||||
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
|
||||
|
||||
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
|
||||
auth = make_resolved_auth()
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
|
||||
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
|
||||
|
||||
def test_build_parser_exposes_safe_helper_commands_without_raw_query(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
subcommands = parser._subparsers._group_actions[0].choices
|
||||
|
||||
self.assertEqual(sorted(subcommands), ["auth", "chats", "delete", "delete-last", "messages", "schema", "search"])
|
||||
self.assertNotIn("query", subcommands)
|
||||
|
||||
|
||||
def test_build_parser_exposes_delete_commands_with_safe_dry_run(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
subcommands = parser._subparsers._group_actions[0].choices
|
||||
|
||||
self.assertIn("delete", subcommands)
|
||||
self.assertIn("delete-last", subcommands)
|
||||
parsed = parser.parse_args(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
|
||||
self.assertEqual(parsed.command, "delete")
|
||||
self.assertEqual(parsed.chat, "팀 공지방")
|
||||
self.assertEqual(parsed.message_id, 42)
|
||||
self.assertTrue(parsed.everyone)
|
||||
self.assertTrue(parsed.dry_run)
|
||||
|
||||
def test_select_delete_target_by_message_id_requires_matching_outbound_message(self) -> None:
|
||||
messages = [
|
||||
{"id": 41, "text": "older", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
|
||||
{"id": 42, "text": "sent follow-up", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
|
||||
]
|
||||
|
||||
target = kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
|
||||
|
||||
self.assertEqual(target.message_id, 42)
|
||||
self.assertEqual(target.text, "sent follow-up")
|
||||
self.assertTrue(target.is_from_me)
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=404, delete_last=False, everyone=False)
|
||||
|
||||
def test_select_delete_target_rejects_non_outbound_message_before_delete_for_me(self) -> None:
|
||||
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
|
||||
|
||||
self.assertIn("sent by this KakaoTalk account", str(context.exception))
|
||||
|
||||
def test_select_delete_last_uses_most_recent_message_from_me(self) -> None:
|
||||
messages = [
|
||||
{"id": 100, "text": "latest inbound", "is_from_me": False, "timestamp": "2026-05-14T00:02:00Z"},
|
||||
{"id": 99, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
|
||||
{"id": 98, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
|
||||
]
|
||||
|
||||
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=True)
|
||||
|
||||
self.assertEqual(target.message_id, 99)
|
||||
self.assertEqual(target.text, "latest outbound")
|
||||
|
||||
def test_select_delete_last_sorts_unordered_messages_by_timestamp_then_id(self) -> None:
|
||||
messages = [
|
||||
{"id": 40, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
|
||||
{"id": 42, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:02:00Z"},
|
||||
{"id": 41, "text": "middle outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
|
||||
]
|
||||
|
||||
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
|
||||
|
||||
self.assertEqual(target.message_id, 42)
|
||||
self.assertEqual(target.text, "latest outbound")
|
||||
|
||||
def test_select_delete_last_uses_id_as_tiebreaker_for_equal_timestamps(self) -> None:
|
||||
messages = [
|
||||
{"id": 40, "text": "same time older id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
|
||||
{"id": 43, "text": "same time newer id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
|
||||
]
|
||||
|
||||
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
|
||||
|
||||
self.assertEqual(target.message_id, 43)
|
||||
self.assertEqual(target.text, "same time newer id")
|
||||
|
||||
def test_select_delete_target_rejects_everyone_for_non_outbound_message(self) -> None:
|
||||
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
|
||||
|
||||
self.assertIn("--everyone", str(context.exception))
|
||||
|
||||
def test_build_delete_osascript_mentions_chat_text_and_delete_scope(self) -> None:
|
||||
target = kakaotalk_mac.DeleteTarget(
|
||||
message_id=42,
|
||||
text="테스트 메시지",
|
||||
timestamp="2026-05-14T00:00:00Z",
|
||||
is_from_me=True,
|
||||
)
|
||||
|
||||
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
|
||||
|
||||
self.assertIn("팀 공지방", script)
|
||||
self.assertIn("테스트 메시지", script)
|
||||
self.assertIn("모두에게서 삭제", script)
|
||||
self.assertIn("Delete for Everyone", script)
|
||||
self.assertIn("matchingElements", script)
|
||||
self.assertIn("Could not choose the requested delete scope", script)
|
||||
|
||||
def test_build_delete_osascript_uses_fail_closed_exact_transcript_resolver(self) -> None:
|
||||
target = kakaotalk_mac.DeleteTarget(
|
||||
message_id=42,
|
||||
text="테스트 메시지",
|
||||
timestamp="2026-05-14T00:00:00Z",
|
||||
is_from_me=True,
|
||||
)
|
||||
|
||||
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
|
||||
|
||||
self.assertNotIn("entire contents of front window", script)
|
||||
self.assertNotIn("contains messageText", script)
|
||||
self.assertNotIn("contains chatName", script)
|
||||
self.assertIn("set normalizedMessageText to normalizeText(messageText)", script)
|
||||
self.assertIn("set normalizedChatName to normalizeText(chatName)", script)
|
||||
self.assertIn("if normalizeText(candidateValue) is normalizedMessageText then", script)
|
||||
self.assertIn("if normalizeText(chatCandidateValue) is normalizedChatName then", script)
|
||||
self.assertIn("set messageListCandidates to", script)
|
||||
self.assertIn("AXShowMenu", script)
|
||||
self.assertIn("Target message text matched multiple visible targetable message bubbles", script)
|
||||
self.assertIn("Could not verify the active KakaoTalk chat", script)
|
||||
self.assertNotIn("set messageTimestamp to", script)
|
||||
|
||||
def test_run_delete_dry_run_validates_target_but_skips_ui_side_effect(self) -> None:
|
||||
stdout = io.StringIO()
|
||||
auth = make_resolved_auth()
|
||||
messages = [{"id": 42, "text": "검증된 메시지", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"}]
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=auth) as resolve_auth,
|
||||
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=messages) as load_messages,
|
||||
mock.patch.object(kakaotalk_mac, "run_delete_automation") as run_delete,
|
||||
mock.patch("sys.stdout", stdout),
|
||||
):
|
||||
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
resolve_auth.assert_called_once()
|
||||
load_messages.assert_called_once_with("팀 공지방", auth, limit=200)
|
||||
run_delete.assert_not_called()
|
||||
self.assertIn("DRY RUN", stdout.getvalue())
|
||||
self.assertIn("message_id=42", stdout.getvalue())
|
||||
self.assertIn("검증된 메시지", stdout.getvalue())
|
||||
|
||||
def test_run_delete_dry_run_fails_when_message_id_is_missing(self) -> None:
|
||||
stderr = io.StringIO()
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=make_resolved_auth()),
|
||||
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=[]),
|
||||
mock.patch("sys.stderr", stderr),
|
||||
):
|
||||
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "404", "--dry-run"])
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertIn("Message id 404", stderr.getvalue())
|
||||
|
||||
def test_select_delete_target_rejects_duplicate_visible_text(self) -> None:
|
||||
messages = [
|
||||
{"id": 42, "text": "same", "is_from_me": True},
|
||||
{"id": 41, "text": "same", "is_from_me": True},
|
||||
]
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
|
||||
|
||||
self.assertIn("same normalized visible text", str(context.exception))
|
||||
|
||||
|
||||
def test_select_delete_target_rejects_duplicate_normalized_visible_text(self) -> None:
|
||||
messages = [
|
||||
{"id": 42, "text": "same visible text", "is_from_me": True},
|
||||
{"id": 41, "text": "same visible text", "is_from_me": True},
|
||||
]
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
|
||||
|
||||
self.assertIn("same normalized visible text", str(context.exception))
|
||||
|
||||
def test_select_delete_target_rejects_empty_or_non_text_delete_target(self) -> None:
|
||||
messages = [{"id": 42, "text": " ", "type": "photo", "is_from_me": True}]
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
|
||||
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
|
||||
|
||||
self.assertIn("non-empty text", str(context.exception))
|
||||
|
||||
def test_build_delete_osascript_fails_when_final_confirmation_is_missing(self) -> None:
|
||||
target = kakaotalk_mac.DeleteTarget(
|
||||
message_id=42,
|
||||
text="테스트 메시지",
|
||||
timestamp="2026-05-14T00:00:00Z",
|
||||
is_from_me=True,
|
||||
)
|
||||
|
||||
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
|
||||
|
||||
self.assertIn("set didConfirmDelete to false", script)
|
||||
self.assertIn("set didConfirmDelete to true", script)
|
||||
self.assertIn("if didConfirmDelete is false then error", script)
|
||||
self.assertIn("Could not confirm the KakaoTalk delete dialog", script)
|
||||
|
||||
def test_build_parser_rejects_negative_max_user_id(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--max-user-id", "-1"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be non-negative", stderr.getvalue())
|
||||
|
||||
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--chunk-size", "0"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be positive", stderr.getvalue())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue