mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
commit
a633b001be
30 changed files with 2802 additions and 63 deletions
|
|
@ -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",
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -42,6 +42,12 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 등기부등본 자동화 | `iros-registry-automation` | 인터넷등기소(IROS)에서 법인/부동산 등기부등본 장바구니, 수동 결제 후 열람·저장 흐름을 보조 | 필요(수동 로그인·결제/TouchEn) | [등기부등본 자동화 가이드](docs/features/iros-registry-automation.md) |
|
||||
| 법인등기 신청 컨설팅 | `corporate-registration-consulting` | 일반 영리 주식회사 발기설립 기준으로 법인명·이사·주소 등 사용자 결정사항을 받아 표준 정관, 설립등기 첨부서류, 등록면허세·과밀억제권역 중과 체크, rhwp 기반 HWP 양식 순차 작성 흐름을 참고용으로 안내 | 불필요 | [법인등기 신청 컨설팅 가이드](docs/features/corporate-registration-consulting.md) |
|
||||
| 사업자등록정보 확인 | `nts-business-registration` | 국세청 사업자등록번호 상태조회와 사업자등록정보 진위확인(공공데이터포털 API, 프록시 경유) | 불필요 | [사업자등록정보 확인 가이드](docs/features/nts-business-registration.md) |
|
||||
| 사업자 실사 종합 | `biz-health-check` | 사업자등록번호를 중심으로, 상호·지역을 함께 주면 국세청 상태·국민연금·체납 명단·금융위 법인개요·부정당제재·인허가 영업상태를 교차 조회한 실사 리포트(점수·판정 없이 사실만) | 불필요 | [사업자 실사 종합 가이드](docs/features/biz-health-check.md) |
|
||||
| 국민연금 가입 사업장 조회 | `national-pension-workplace` | 사업장명으로 국민연금 가입자수·당월 고지금액·월별 추이 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [국민연금 가입 사업장 조회 가이드](docs/features/national-pension-workplace.md) |
|
||||
| 국세 체납 명단공개 검색 | `nts-tax-delinquency` | 상호·법인명으로 국세청 고액·상습체납자 명단공개 대조(무인증 공개 검색) | 불필요 | [국세 체납 명단공개 검색 가이드](docs/features/nts-tax-delinquency.md) |
|
||||
| 금융위 기업기본정보 조회 | `fsc-corporate-info` | 법인명으로 대표자·설립일·업종 등 법인 개요 조회와 사업자번호 교차검증(공공데이터포털 API, 프록시 경유) | 불필요 | [금융위 기업기본정보 조회 가이드](docs/features/fsc-corporate-info.md) |
|
||||
| 부정당제재업체 조회 | `g2b-sanctioned-supplier` | 사업자번호로 나라장터 부정당제재(조회시점 유효 제재) 조회(공공데이터포털 API, 프록시 경유) | 불필요 | [부정당제재업체 조회 가이드](docs/features/g2b-sanctioned-supplier.md) |
|
||||
| 인허가 영업상태 조회 | `localdata-business-status` | 상호+시군구로 동네 사업장(208업종)의 영업/휴업/폐업·업력·주소 조회(LOCALDATA 무인증) | 불필요 | [인허가 영업상태 조회 가이드](docs/features/localdata-business-status.md) |
|
||||
| 창업진흥원 K-Startup 조회 | `kstartup-search` | 창업진흥원 K-Startup 통합공고 사업·지원사업 공고·창업 콘텐츠·통계보고서 조회 (공공데이터포털 15125364, 프록시 경유) | 불필요 | [창업진흥원 K-Startup 조회 가이드](docs/features/kstartup-search.md) |
|
||||
| 지방선거 후보자 조회 | `local-election-candidate-search` | 중앙선거관리위원회 선거통계시스템 공개 통합검색으로 지방선거 후보자 이력·선거종류·정당·지역·득표 정보를 이름 기준으로 조회 | 불필요 | [지방선거 후보자 조회 가이드](docs/features/local-election-candidate-search.md) |
|
||||
| 한국 사업자 장부 자동화 | `korean-jangbu-for` | `kimlawtech/korean-jangbu-for` 기반 카드·은행·영수증·세금계산서 입력 → 표준 거래내역·계정과목·세무사 전달 CSV·경영 리포트 생성 thin wrapper | 선택사항(CODEF BYOK 자동 수집 시 필요) | [한국 사업자 장부 자동화 가이드](docs/features/korean-jangbu-for.md) |
|
||||
|
|
@ -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
79
biz-health-check/SKILL.md
Normal 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`)의 공식 출처를 따른다.
|
||||
161
biz-health-check/scripts/biz_health_check.py
Normal file
161
biz-health-check/scripts/biz_health_check.py
Normal 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())
|
||||
45
docs/features/biz-health-check.md
Normal file
45
docs/features/biz-health-check.md
Normal 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)의 "사업자 실사" 항목 참조.
|
||||
35
docs/features/fsc-corporate-info.md
Normal file
35
docs/features/fsc-corporate-info.md
Normal 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`
|
||||
41
docs/features/g2b-sanctioned-supplier.md
Normal file
41
docs/features/g2b-sanctioned-supplier.md
Normal 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`
|
||||
44
docs/features/localdata-business-status.md
Normal file
44
docs/features/localdata-business-status.md
Normal 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>
|
||||
38
docs/features/national-pension-workplace.md
Normal file
38
docs/features/national-pension-workplace.md
Normal 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`
|
||||
31
docs/features/nts-tax-delinquency.md
Normal file
31
docs/features/nts-tax-delinquency.md
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
67
fsc-corporate-info/SKILL.md
Normal file
67
fsc-corporate-info/SKILL.md
Normal 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`
|
||||
109
fsc-corporate-info/scripts/fsc_corporate_info.py
Normal file
109
fsc-corporate-info/scripts/fsc_corporate_info.py
Normal 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())
|
||||
71
g2b-sanctioned-supplier/SKILL.md
Normal file
71
g2b-sanctioned-supplier/SKILL.md
Normal 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`
|
||||
110
g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py
Normal file
110
g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py
Normal 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())
|
||||
74
localdata-business-status/SKILL.md
Normal file
74
localdata-business-status/SKILL.md
Normal 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>
|
||||
210
localdata-business-status/data/localdata_industries.json
Normal file
210
localdata-business-status/data/localdata_industries.json
Normal 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": "문화_청소년게임제공업"
|
||||
}
|
||||
247
localdata-business-status/data/localdata_orgcodes.json
Normal file
247
localdata-business-status/data/localdata_orgcodes.json
Normal 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"
|
||||
}
|
||||
206
localdata-business-status/scripts/localdata_business_status.py
Normal file
206
localdata-business-status/scripts/localdata_business_status.py
Normal 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())
|
||||
74
national-pension-workplace/SKILL.md
Normal file
74
national-pension-workplace/SKILL.md
Normal 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`
|
||||
109
national-pension-workplace/scripts/national_pension_workplace.py
Normal file
109
national-pension-workplace/scripts/national_pension_workplace.py
Normal 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())
|
||||
64
nts-tax-delinquency/SKILL.md
Normal file
64
nts-tax-delinquency/SKILL.md
Normal 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`
|
||||
150
nts-tax-delinquency/scripts/nts_tax_delinquency.py
Normal file
150
nts-tax-delinquency/scripts/nts_tax_delinquency.py
Normal 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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
157
packages/k-skill-proxy/src/fsc-corp.js
Normal file
157
packages/k-skill-proxy/src/fsc-corp.js
Normal 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,
|
||||
};
|
||||
134
packages/k-skill-proxy/src/g2b-sanction.js
Normal file
134
packages/k-skill-proxy/src/g2b-sanction.js
Normal 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,
|
||||
};
|
||||
199
packages/k-skill-proxy/src/national-pension.js
Normal file
199
packages/k-skill-proxy/src/national-pension.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue