Skill: muninjun/mij-kakao-local (version 1.0.0)

# Kakao Local API Skill **OpenClaw Skill for Kakao Local (Places & Address) API** ## 개요 카카오 로컬 API를 호출하여 주소 정규화 및 장소 검색을 수행하는 OpenClaw Skill입니다. ## 요구사항 - Windows - PowerShell 5.0+ - curl.exe (Windows 10+ 기본 포함) - Kakao Developers REST API Key ## API Key 설정 **중요**: API Key는 스킬 파라미터로 전달하지 않습니다 (로그 노출 방지). ### 방법 1: 환경변수 (권장) ```powershell # 사용자 환경변수로 영구 설정 [Environment]::SetEnvironmentVariable("KAKAO_REST_API_KEY", "your_rest_api_key_here", "User") # 또는 현재 세션에만 임시 설정 $env:KAKAO_REST_API_KEY = "your_rest_api_key_here" ``` ### 방법 2: Config 파일 `skills/kakao-local/data/config.json` (create this file) 생성: ```json { "api_key": "your_rest_api_key_here" } ``` **⚠️ 주의**: `config.json`은 `.gitignore`에 추가하여 커밋 금지 ### API Key 발급 1. [Kakao Developers](https://developers.kakao.com/) 접속 2. 내 애플리케이션 → 앱 추가 3. 앱 키 → REST API 키 복사 ## 스킬 함수 ### 1. NormalizeAddress (주소 정규화) 사용자가 입력한 주소를 정규화하여 도로명/지번 주소와 좌표로 변환합니다. **API 엔드포인트**: `GET https://dapi.kakao.com/v2/local/search/address.json` **입력 파라미터**: - `-Action "NormalizeAddress"` (필수) - `-Query "주소 문자열"` (필수) - `-Size 3` (선택, 기본값: 3) **출력 형식**: ```json { "ok": true, "action": "NormalizeAddress", "query": "서울 강남구 테헤란로 152", "count": 2, "candidates": [ { "roadAddress": "서울 강남구 테헤란로 152", "jibunAddress": "서울 강남구 역삼동 737", "x": "127.036557561809", "y": "37.4985995780801", "region": { "region1": "서울", "region2": "강남구", "region3": "역삼동" }, "buildingName": "강남파이낸스센터", "zoneNo": "06236" } ], "raw": {} } ``` **사용 예시**: ```powershell .\scripts\kakao_local.ps1 -Action NormalizeAddress -Query "판교역로 235" .\scripts\kakao_local.ps1 -Action NormalizeAddress -Query "서울 강남구" -Size 5 ``` ### 2. SearchPlace (키워드 장소 검색) 키워드로 장소를 검색합니다. 위치 기반 반경 검색과 카테고리 필터링을 지원합니다. **API 엔드포인트**: `GET https://dapi.kakao.com/v2/local/search/keyword.json` **입력 파라미터**: - `-Action "SearchPlace"` (필수) - `-Query "검색 키워드"` (필수) - `-Size 5` (선택, 기본값: 5, 최대: 15) - `-Page 1` (선택, 기본값: 1, 최대: 45) - `-X "127.027"` (선택, 중심 경도) - `-Y "37.498"` (선택, 중심 위도) - `-Radius 1000` (선택, 검색 반경(m), 최대: 20000) - `-CategoryGroupCode "CE7"` (선택, 카테고리 그룹 코드) **카테고리 그룹 코드**: - MT1: 대형마트 - CS2: 편의점 - PS3: 어린이집, 유치원 - SC4: 학교 - AC5: 학원 - PK6: 주차장 - OL7: 주유소, 충전소 - SW8: 지하철역 - BK9: 은행 - CT1: 문화시설 - AG2: 중개업소 - PO3: 공공기관 - AT4: 관광명소 - AD5: 숙박 - FD6: 음식점 - CE7: 카페 - HP8: 병원 - PM9: 약국 **출력 형식**: ```json { "ok": true, "action": "SearchPlace", "query": "대형카페", "count": 5, "totalCount": 128, "isEnd": false, "items": [ { "id": "8739036", "name": "스타벅스 강남점", "roadAddress": "서울 강남구 테헤란로 152", "jibunAddress": "서울 강남구 역삼동 737", "x": "127.036557561809", "y": "37.4985995780801", "phone": "02-1234-5678", "categoryName": "음식점 > 카페", "placeUrl": "http://place.map.kakao.com/8739036", "distance": "245" } ], "raw": {} } ``` **사용 예시**: ```powershell # 기본 검색 .\scripts\kakao_local.ps1 -Action SearchPlace -Query "대형카페" # 개수 지정 .\scripts\kakao_local.ps1 -Action SearchPlace -Query "브런치 맛집" -Size 10 # 위치 기반 반경 검색 .\scripts\kakao_local.ps1 -Action SearchPlace -Query "카페" -X "127.027" -Y "37.498" -Radius 1000 # 카테고리 필터링 .\scripts\kakao_local.ps1 -Action SearchPlace -Query "카페" -CategoryGroupCode "CE7" -Size 15 # 페이지네이션 .\scripts\kakao_local.ps1 -Action SearchPlace -Query "주차 가능한 카페" -Page 2 -Size 10 ``` ## 에러 처리 ### API Key 없음 ```json { "ok": false, "errorType": "MissingApiKey", "message": "Set KAKAO_REST_API_KEY env var or create config.json", "setupGuide": "https://developers.kakao.com/" } ``` ### API Key 잘못됨 (401/403) ```json { "ok": false, "errorType": "InvalidApiKey", "message": "Invalid or expired API key", "statusCode": 401 } ``` ### API 호출 실패 ```json { "ok": false, "errorType": "ApiError", "message": "Failed to call Kakao API", "details": "..." } ``` ### 결과 없음 ```json { "ok": true, "action": "SearchPlace", "query": "존재하지않는장소12345", "count": 0, "items": [] } ``` ## 통합 예시 (상위 에이전트/챗봇) ```powershell # 주소 정규화 후 즐겨찾기 저장 $result = .\skills\kakao-local\scripts\kakao_local.ps1 -Action NormalizeAddress -Query "홍대입구역" $data = $result | ConvertFrom-Json if ($data.ok -and $data.count -gt 0) { $best = $data.candidates[0] # 즐겨찾기에 추가 $places = Get-Content ".\skills\kakao-local\data\places.json" -Raw | ConvertFrom-Json $places | Add-Member -NotePropertyName "홍대" -NotePropertyValue @{ roadAddress = $best.roadAddress x = $best.x y = $best.y savedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") } -Force $places | ConvertTo-Json -Depth 10 | Out-File ".\skills\kakao-local\data\places.json" -Encoding UTF8 Write-Host "✅ 즐겨찾기 저장: 홍대 → $($best.roadAddress)" } # 장소 검색 후 상위 3개 추천 $result = .\skills\kakao-local\scripts\kakao_local.ps1 -Action SearchPlace -Query "주차 가능한 카페" -Size 10 $data = $result | ConvertFrom-Json if ($data.ok -and $data.count -gt 0) { Write-Host "`n🌟 추천 장소 TOP 3:" $top3 = $data.items | Select-Object -First 3 $index = 1 foreach ($place in $top3) { Write-Host "`n[$index] $($place.name)" Write-Host " 📍 $($place.roadAddress)" Write-Host " 📞 $($place.phone)" Write-Host " 🔗 $($place.placeUrl)" $index++ } # 캐시에 저장 (중복 검색 방지) $cache = @{ query = $data.query timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss") ttl = 3600 # 1시간 results = $data.items } $cache | ConvertTo-Json -Depth 10 | Out-File ".\skills\kakao-local\data\cache.json" -Encoding UTF8 } ``` ## 테스트 시나리오 ### 1. 주소 정규화 테스트 ```powershell .\scripts\kakao_local.ps1 -Action NormalizeAddress -Query "서울 강남구 테헤란로 152" # 기대: 도로명/지번 주소와 좌표 출력 ``` ### 2. 장소 검색 테스트 ```powershell .\scripts\kakao_local.ps1 -Action SearchPlace -Query "대형카페" -Size 5 # 기대: 5개 카페 목록 출력 ``` ### 3. API Key 미설정 테스트 ```powershell # 환경변수 임시 제거 $backup = $env:KAKAO_REST_API_KEY $env:KAKAO_REST_API_KEY = $null .\scripts\kakao_local.ps1 -Action SearchPlace -Query "카페" # 기대: {"ok": false, "errorType": "MissingApiKey", ...} # 복구 $env:KAKAO_REST_API_KEY = $backup ``` ### 4. 잘못된 API Key 테스트 ```powershell $env:KAKAO_REST_API_KEY = "invalid_key_12345" .\scripts\kakao_local.ps1 -Action SearchPlace -Query "카페" # 기대: {"ok": false, "errorType": "InvalidApiKey", ...} ``` ## 파일 구조 ``` skills/kakao-local/ ├── SKILL.md # 이 파일 (스킬 명세) ├── README.md # Quick Start ├── .gitignore # config.json 보호 ├── scripts/ │ └── kakao_local.ps1 # 메인 스킬 스크립트 └── data/ ├── config.json.template # API Key 설정 템플릿 ├── places.json # 즐겨찾기 (선택) └── cache.json # 검색 캐시 (선택) ``` ## 라이선스 MIT License --- ## Publish-safe packaging note This registry upload is "text-only" compatible: script sources are embedded under `references/` as Markdown. To use the skill locally: 1) Copy `references/kakao_local.ps1.md` content into a file: `scripts/kakao_local.ps1` 2) Copy `references/config.json.template.md` content into: `data/config.json.template` 3) Set API key via env var `KAKAO_REST_API_KEY` (recommended) or create `data/config.json` (gitignored).

path: references/config.json.template.md
content: # config.json.template\n\n`json\n{
  "api_key": "your_rest_api_key_here"
}
\n`\n


path: references/kakao_local.ps1.md
content: # kakao_local.ps1\n\n`powershell\n<#
.SYNOPSIS
    Kakao Local API Skill for OpenClaw

.DESCRIPTION
    카카오 로컬 API를 curl.exe로 호출하여 주소 정규화 및 장소 검색 수행

.PARAMETER Action
    수행할 작업: NormalizeAddress, SearchPlace

.PARAMETER Query
    검색 쿼리 (주소 또는 키워드) - 필수

.PARAMETER Size
    결과 개수 (기본값: NormalizeAddress=3, SearchPlace=5)

.PARAMETER Page
    페이지 번호 (기본값: 1)

.PARAMETER X
    중심 좌표 경도 (선택)

.PARAMETER Y
    중심 좌표 위도 (선택)

.PARAMETER Radius
    검색 반경(m) (기본값: 20000)

.PARAMETER CategoryGroupCode
    카테고리 그룹 코드 (선택)

.PARAMETER Output
    출력 형식 (기본값: json, 고정)

.EXAMPLE
    .\kakao_local.ps1 -Action NormalizeAddress -Query "서울 강남구 테헤란로 152"

.EXAMPLE
    .\kakao_local.ps1 -Action SearchPlace -Query "대형카페" -Size 10

.EXAMPLE
    .\kakao_local.ps1 -Action SearchPlace -Query "카페" -X "127.027" -Y "37.498" -Radius 1000
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory=$true)]
    [ValidateSet("NormalizeAddress", "SearchPlace")]
    [string]$Action,

    [Parameter(Mandatory=$true)]
    [string]$Query,

    [Parameter(Mandatory=$false)]
    [int]$Size = 0,

    [Parameter(Mandatory=$false)]
    [int]$Page = 1,

    [Parameter(Mandatory=$false)]
    [string]$X,

    [Parameter(Mandatory=$false)]
    [string]$Y,

    [Parameter(Mandatory=$false)]
    [int]$Radius = 20000,

    [Parameter(Mandatory=$false)]
    [string]$CategoryGroupCode,

    [Parameter(Mandatory=$false)]
    [string]$Output = "json"
)

# UTF-8 출력 강제 설정 (PS 5.x 인코딩 깨짐 방지: BOM 없는 UTF-8)
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$OutputEncoding = $utf8NoBom
[Console]::OutputEncoding = $utf8NoBom

# ============================================
# API Key 로드 함수
# ============================================
function Get-ApiKey {
    # 1순위: 환경변수 (User)
    $[REDACTED_SECRET]"KAKAO_REST_API_KEY", "User")
    if (-not [string]::IsNullOrEmpty($apiKey)) {
        return $apiKey
    }

    # 2순위: 환경변수 (Process)
    $[REDACTED_SECRET]"KAKAO_REST_API_KEY", "Process")
    if (-not [string]::IsNullOrEmpty($apiKey)) {
        return $apiKey
    }

    $[REDACTED_SECRET]
    if (-not [string]::IsNullOrEmpty($apiKey)) {
        return $apiKey
    }

    # 3순위: config.json 파일
    $configPath = Join-Path $PSScriptRoot "..\data\config.json"
    if (Test-Path $configPath) {
        try {
            $config = Get-Content $configPath -Raw -Encoding UTF8 | ConvertFrom-Json
            if ($config.api_key -and $config.api_key -ne "your_rest_api_key_here") {
                return $config.api_key
            }
        }
        catch {
            # 파일 읽기 실패 시 무시
        }
    }

    return $null
}

# ============================================
# curl.exe로 API 호출 함수
# ============================================
function Invoke-KakaoApiWithCurl {
    param(
        [string]$Endpoint,
        [hashtable]$Params,
        [string]$ApiKey
    )

    try {
        # URL 인코딩된 쿼리 문자열 생성
        $queryParts = @()
        foreach ($key in $Params.Keys) {
            $value = [System.Uri]::EscapeDataString($Params[$key])
            $queryParts += "${key}=${value}"
        }
        $queryString = $queryParts -join "&"

        $url = "${Endpoint}?${queryString}"

        # curl.exe 명령 실행 (PowerShell의 curl 별칭 회피)
        $curlPath = "curl.exe"

        # 헤더 설정
        $authHeader = "Authorization: KakaoAK ${ApiKey}"

        # curl.exe 실행
        $response = & $curlPath -s -X GET $url -H $authHeader -H "Content-Type: application/json;charset=UTF-8"

        # HTTP 상태 코드 확인을 위한 재호출 (헤더 포함)
        $statusLine = & $curlPath -s -I -X GET $url -H $authHeader | Select-String "HTTP/"

        # JSON 파싱
        if ($response) {
            $jsonResponse = $response | ConvertFrom-Json

            # 에러 응답 확인
            if ($jsonResponse.errorType -or $jsonResponse.error) {
                return @{
                    success = $false
                    statusCode = 401
                    response = $jsonResponse
                }
            }

            return @{
                success = $true
                statusCode = 200
                response = $jsonResponse
            }
        }
        else {
            throw "Empty response from API"
        }
    }
    catch {
        return @{
            success = $false
            statusCode = 0
            error = $_.Exception.Message
        }
    }
}

# ============================================
# 주소 정규화 함수
# ============================================
function Invoke-NormalizeAddress {
    param(
        [string]$Query,
        [int]$Size,
        [string]$ApiKey
    )

    # Size 기본값 설정
    if ($Size -eq 0) {
        $Size = 3
    }

    $params = @{
        query = $Query
        size = $Size.ToString()
    }

    $result = Invoke-KakaoApiWithCurl -Endpoint "https://dapi.kakao.com/v2/local/search/address.json" -Params $params -ApiKey $ApiKey

    if (-not $result.success) {
        # API 에러 처리
        if ($result.statusCode -eq 401 -or $result.statusCode -eq 403) {
            return @{
                ok = $false
                errorType = "InvalidApiKey"
                message = "Invalid or expired API key"
                statusCode = $result.statusCode
            } | ConvertTo-Json -Compress
        }
        else {
            return @{
                ok = $false
                errorType = "ApiError"
                message = "Failed to call Kakao API"
                details = $result.error
            } | ConvertTo-Json -Compress
        }
    }

    $apiResponse = $result.response

    # 응답 데이터 가공
    $candidates = @()
    foreach ($doc in $apiResponse.documents) {
        $candidate = @{
            roadAddress = ""
            jibunAddress = ""
            x = $doc.x
            y = $doc.y
            region = @{}
            buildingName = ""
            zoneNo = ""
        }

        # 지번 주소
        if ($doc.address) {
            $candidate.jibunAddress = $doc.address.address_name
            $candidate.region = @{
                region1 = $doc.address.region_1depth_name
                region2 = $doc.address.region_2depth_name
                region3 = $doc.address.region_3depth_name
            }
        }

        # 도로명 주소
        if ($doc.road_address) {
            $candidate.roadAddress = $doc.road_address.address_name
            $candidate.buildingName = $doc.road_address.building_name
            $candidate.zoneNo = $doc.road_address.zone_no
        }
        else {
            $candidate.roadAddress = $candidate.jibunAddress
        }

        $candidates += $candidate
    }

    # 출력 규격에 맞춘 결과
    $output = @{
        ok = $true
        action = "NormalizeAddress"
        query = $Query
        count = $candidates.Count
        candidates = $candidates
        raw = $apiResponse
    }

    return $output | ConvertTo-Json -Depth 10 -Compress
}

# ============================================
# 장소 검색 함수
# ============================================
function Invoke-SearchPlace {
    param(
        [string]$Query,
        [int]$Size,
        [int]$Page,
        [string]$X,
        [string]$Y,
        [int]$Radius,
        [string]$CategoryGroupCode,
        [string]$ApiKey
    )

    # Size 기본값 설정
    if ($Size -eq 0) {
        $Size = 5
    }

    $params = @{
        query = $Query
        size = $Size.ToString()
        page = $Page.ToString()
    }

    # 선택적 파라미터 추가
    if ($X -and $Y) {
        $params.Add("x", $X)
        $params.Add("y", $Y)
        $params.Add("radius", $Radius.ToString())
    }

    if ($CategoryGroupCode) {
        $params.Add("category_group_code", $CategoryGroupCode)
    }

    $result = Invoke-KakaoApiWithCurl -Endpoint "https://dapi.kakao.com/v2/local/search/keyword.json" -Params $params -ApiKey $ApiKey

    if (-not $result.success) {
        # API 에러 처리
        if ($result.statusCode -eq 401 -or $result.statusCode -eq 403) {
            return @{
                ok = $false
                errorType = "InvalidApiKey"
                message = "Invalid or expired API key"
                statusCode = $result.statusCode
            } | ConvertTo-Json -Compress
        }
        else {
            return @{
                ok = $false
                errorType = "ApiError"
                message = "Failed to call Kakao API"
                details = $result.error
            } | ConvertTo-Json -Compress
        }
    }

    $apiResponse = $result.response

    # 응답 데이터 가공
    $items = @()
    foreach ($doc in $apiResponse.documents) {
        $item = @{
            id = $doc.id
            name = $doc.place_name
            roadAddress = $doc.road_address_name
            jibunAddress = $doc.address_name
            x = $doc.x
            y = $doc.y
            phone = $doc.phone
            categoryName = $doc.category_name
            placeUrl = $doc.place_url
            distance = $doc.distance
        }

        $items += $item
    }

    # 출력 규격에 맞춘 결과
    $output = @{
        ok = $true
        action = "SearchPlace"
        query = $Query
        count = $items.Count
        totalCount = $apiResponse.meta.total_count
        isEnd = $apiResponse.meta.is_end
        items = $items
        raw = $apiResponse
    }

    return $output | ConvertTo-Json -Depth 10 -Compress
}

# ============================================
# 메인 실행 로직
# ============================================

# API Key 확인
$[REDACTED_SECRET]

if ([string]::IsNullOrEmpty($apiKey)) {
    $errorOutput = @{
        ok = $false
        errorType = "MissingApiKey"
        message = "Set KAKAO_REST_API_KEY env var or create config.json"
        setupGuide = "https://developers.kakao.com/"
        instructions = @(
            "Method 1: Environment Variable",
            '  [Environment]::SetEnvironmentVariable("KAKAO_REST_API_KEY", "your_key", "User")',
            "",
            "Method 2: Config File",
            "  Path: skills/kakao-local/data/config.json",
            '  Content: {"api_key": "your_rest_api_key_here"}'
        )
    }

    Write-Output ($errorOutput | ConvertTo-Json -Depth 10 -Compress)
    exit 1
}

# Action 실행
switch ($Action) {
    "NormalizeAddress" {
        $result = Invoke-NormalizeAddress -Query $Query -Size $Size -ApiKey $apiKey
        Write-Output $result
    }
    "SearchPlace" {
        $result = Invoke-SearchPlace -Query $Query -Size $Size -Page $Page -X $X -Y $Y -Radius $Radius -CategoryGroupCode $CategoryGroupCode -ApiKey $apiKey
        Write-Output $result
    }
}
\n`\n


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (MEDIUM)