feat: add business due-diligence skills

Reviewed, resolved conflicts with latest dev, applied follow-up fixes, and verified with npm run ci.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-06-12 19:35:14 +09:00 committed by GitHub
commit a633b001be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2802 additions and 63 deletions

View file

@ -9,6 +9,7 @@
"repository": "https://github.com/NomaDamas/k-skill",
"license": "MIT",
"skills": [
"./biz-health-check",
"./bunjang-search",
"./catchtable-sniper",
"./cheap-gas-nearby",
@ -29,6 +30,8 @@
"./fine-dust-location",
"./flight-ticket-search",
"./foresttrip-vacancy",
"./fsc-corporate-info",
"./g2b-sanctioned-supplier",
"./gangnamunni-clinic-search",
"./geeknews-search",
"./gongsijiga-search",
@ -72,15 +75,18 @@
"./lh-notice-search",
"./library-book-search",
"./local-election-candidate-search",
"./localdata-business-status",
"./lotto-results",
"./market-kurly-search",
"./mfds-drug-safety",
"./mfds-food-safety",
"./myrealtrip-search",
"./national-pension-workplace",
"./naver-blog-research",
"./naver-news-search",
"./naver-shopping-search",
"./nts-business-registration",
"./nts-tax-delinquency",
"./ohou-today-deal",
"./olive-young-search",
"./parking-lot-search",

View file

@ -42,6 +42,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호를 중심으로, 상호·지역을 함께 주면 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
| 국민연금 가입 사업장 조회 | `national-pension-workplace` | 사업장명으로 국민연금 가입자수·당월 고지금액·월별 추이 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md) |
| 국세 체납 명단공개 검색 | `nts-tax-delinquency` | 상호·법인명으로 국세청 고액·상습체납자 명단공개 대조(무인증 공개 검색) | 불필요 | [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md) |
| 금융위 기업기본정보 조회 | `fsc-corporate-info` | 법인명으로 대표자·설립일·업종 등 법인 개요 조회와 사업자번호 교차검증(공공데이터포털 API, 프록시 경유) | 불필요 | [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md) |
| 부정당제재업체 조회 | `g2b-sanctioned-supplier` | 사업자번호로 나라장터 부정당제재(조회시점 유효 제재) 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md) |
| 인허가 영업상태 조회 | `localdata-business-status` | 상호+시군구로 동네 사업장(208업종)의 영업/휴업/폐업·업력·주소 조회(LOCALDATA 무인증) | 불필요 | [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.md) |
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
| 지방선거 후보자 조회 | `local-election-candidate-search` | 중앙선거관리위원회 선거통계시스템 공개 통합검색으로 지방선거 후보자 이력·선거종류·정당·지역·득표 정보를 이름 기준으로 조회 | 불필요 | [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md) |
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
@ -164,6 +170,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 개인정보처리방침·이용약관 자동 생성 가이드](docs/features/korean-privacy-terms.md)
- [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md)
- [사업자 실사 종합 가이드](docs/features/biz-health-check.md)
- [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md)
- [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md)
- [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md)
- [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md)
- [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.md)
- [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md)
- [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md)
- [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md)

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -220,3 +220,15 @@
- **기술보증기금**: 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/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_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",

View file

@ -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/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/korean-law.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/g2b-sanction.js && node --check src/fsc-corp.js && node --check src/national-pension.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/korean-law.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

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

View file

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

View file

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

View file

@ -42,6 +42,9 @@ 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,
@ -1897,6 +1900,9 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
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)
},
auth: {
@ -3375,6 +3381,94 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
reply
}));
// Shared handler for keyed data.go.kr GET lookups that reuse the operator's
// DATA_GO_KR_API_KEY server-side (national pension, FSC corp, G2B sanctions).
async function handleKeyedDataGoKrLookup({ route, normalizer, fetcher, request, reply }) {
let normalized;
try {
normalized = normalizer(request.query || {});
} catch (error) {
reply.code(400);
return { error: "bad_request", message: error.message };
}
if (!config.molitApiKey) {
reply.code(503);
return {
error: "upstream_not_configured",
message: "DATA_GO_KR_API_KEY is not configured on the proxy server.",
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const cacheKey = makeCacheKey({ route, ...normalized });
const cached = cache.get(cacheKey);
if (cached) {
return {
...cached,
proxy: { ...cached.proxy, cache: { hit: true, ttl_ms: config.cacheTtlMs } }
};
}
let result;
try {
result = await fetcher({ ...normalized, serviceKey: config.molitApiKey });
} catch (error) {
reply.code(502);
return {
error: "proxy_error",
message: error.message,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs } }
};
}
const keyedErrorStatus = {
upstream_forbidden: 502,
upstream_timeout: 504,
upstream_invalid_response: 502,
upstream_error: 502
};
if (result && result.error) {
reply.code(keyedErrorStatus[result.error] || 502);
return {
...result,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
};
}
const payload = {
...result,
proxy: { name: config.proxyName, cache: { hit: false, ttl_ms: config.cacheTtlMs }, requested_at: new Date().toISOString() }
};
cache.set(cacheKey, payload, config.cacheTtlMs);
return payload;
}
app.get("/v1/national-pension/workplace", async (request, reply) => handleKeyedDataGoKrLookup({
route: "national-pension-workplace",
normalizer: normalizeNationalPensionQuery,
fetcher: fetchNationalPensionWorkplace,
request,
reply
}));
app.get("/v1/fsc/corp-outline", async (request, reply) => handleKeyedDataGoKrLookup({
route: "fsc-corp-outline",
normalizer: normalizeFscCorpQuery,
fetcher: fetchFscCorpOutline,
request,
reply
}));
app.get("/v1/g2b/sanctioned-supplier", async (request, reply) => handleKeyedDataGoKrLookup({
route: "g2b-sanctioned-supplier",
normalizer: normalizeG2bSanctionQuery,
fetcher: fetchG2bSanctions,
request,
reply
}));
async function handleKstartupRoute({ operation, route, request, reply }) {
let normalized;
try {

View file

@ -5670,110 +5670,270 @@ test("K-Startup integer fields reject non-numeric input before upstream call", a
assert.equal(called, false, "upstream must not be called for any invalid integer input");
});
test("korean-law search endpoint proxies law.go.kr with the server OC and browser headers", async (t) => {
const originalFetch = global.fetch;
let calledUrl = null;
let calledHeaders = null;
global.fetch = async (url, options) => {
calledUrl = String(url);
calledHeaders = options.headers;
return new Response(JSON.stringify({ PrecSearch: { prec: [{ 사건번호: "2023두54914" }] } }), {
status: 200,
headers: { "content-type": "application/json;charset=UTF-8" }
});
};
// ---------------------------------------------------------------------------
// Business due-diligence keyed routes: national pension, FSC corp, G2B sanction
// ---------------------------------------------------------------------------
const app = buildServer({ env: { LAW_OC: "server-oc" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
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"
});
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/");
assert.throws(() => normalizeNationalPensionQuery({ name: "테스트상사", b_no: "123" }), /10-digit/);
});
test("korean-law search endpoint caches successful upstream responses", async (t) => {
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;
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 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: { LAW_OC: "server-oc" } });
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-go-key" } });
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 });
const res = await app.inject({
method: "GET",
url: "/v1/national-pension/workplace?name=" + encodeURIComponent("테스트상사") + "&b_no=1234567890"
});
const body = res.json();
assert.equal(first.json().proxy.cache.hit, false);
assert.equal(second.json().proxy.cache.hit, true);
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("korean-law detail endpoint routes to lawService.do", async (t) => {
test("fsc corp-outline route maps upstream 403 to a 502 forbidden error", 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" } });
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 response = await app.inject({
const res = await app.inject({
method: "GET",
url: "/v1/korean-law/detail?target=prec&ID=228541"
url: "/v1/fsc/corp-outline?name=" + encodeURIComponent("테스트")
});
assert.equal(response.statusCode, 200);
assert.match(calledUrl, /\/DRF\/lawService\.do\?/);
assert.match(calledUrl, /ID=228541/);
assert.equal(res.statusCode, 502);
assert.equal(res.json().error, "upstream_forbidden");
});
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 });
};
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" });
});
const app = buildServer({ env: { LAW_OC: "server-oc" } });
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);
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) => {