instruction.md
# 네이버 검색광고 성과 조회
## What this skill does
네이버 검색광고 API(HMAC-SHA256 서명 인증)를 감싸서 광고 계정의 성과 지표를 조회한다. **읽기 전용**이며 입찰가·캠페인·키워드를 바꾸는 쓰기 작업은 하지 않는다.
- 캠페인/광고그룹/키워드 목록 조회
- 기간별 성과(노출수·클릭수·광고비·CTR·CPC·평균노출순위·전환수) 조회
- 연관키워드·월간 조회수·경쟁정도 조회 (키워드 도구)
- 키 셋업 여부와 네이버 API 도달성 진단(`doctor`)
## When to use
- "네이버 광고 노출수/클릭수/광고비/CTR 보여줘"
- "이번달 캠페인 성과 어때"
- "이 키워드 CTR·CPC 얼마야"
- "연관키워드랑 월간 조회수 뽑아줘"
- "네이버 검색광고 키 셋업이 됐는지 확인해줘"
## When not to use
- **클라우드 샌드박스 환경**: 네이버 검색광고 API는 로컬 egress가 필요하다. 클라우드 실행 에이전트(허용 도메인 목록 방식)에서는 도달하지 못할 수 있다 — 먼저 `doctor`로 확인한다.
- **입찰가 변경, 캠페인/키워드 생성·수정·삭제**: 이 스킬은 조회 전용이다. 광고 운영에 영향을 주는 쓰기 작업은 이 스킬 범위 밖이며 구현돼 있지 않다.
- 네이버 검색(블로그/뉴스) 결과 조회는 `naver-blog-research`, `naver-news-search` 스킬을 쓴다 — 이 스킬과는 다른 API다.
## Prerequisites
- Python 3.9+
- 네이버 검색광고 계정 (searchad.naver.com) → **도구 > API 사용 관리**에서 API 키/시크릿 발급
- 환경변수 3개: `NAVER_AD_API_KEY`, `NAVER_AD_SECRET_KEY`, `NAVER_AD_CUSTOMER_ID`
키는 사용자가 직접 발급한다. 돌쇠에서는 에이전트가 값을 보지 않도록 provisioned `vault-run` capability를 사용하고, 없으면 `request_vault_credential`로 앱 vault 입력 UI를 호출한다. generic fallback에서는 환경변수/host vault/`secrets.env` 순서를 사용한다.
## Workflow
### 1. 진단
```bash
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- doctor
```
키 3개 존재 여부와 `api.searchad.naver.com` 도달 가능 여부를 보여준다. `reachable: false`면 로컬(클로드 코드/코덱스 CLI) 환경에서 다시 실행하도록 안내한다.
### 2. 구조 조회 (id가 필요할 때)
```bash
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- campaigns
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- adgroups --campaign <nccCampaignId>
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- keywords --adgroup <nccAdgroupId>
```
### 3. 성과 조회 (핵심)
```bash
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- stats --ids <id1,id2> --since 2026-06-01 --until 2026-06-30
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- stats --ids <id> --since 2026-06-01 --until 2026-06-30 --by day
```
`ids`는 캠페인/광고그룹/키워드 id를 콤마로 구분해 섞어 넣을 수 있다. `--since`/`--until`을 생략하면 네이버 기본 기간(최근)으로 조회된다.
### 4. 키워드 도구
```bash
npx -y @nomadamas/k-skill@0 exec naver-ad-performance scripts/naver_ad_performance.py -- keywordtool --keywords "제주여행,게스트하우스"
```
## Signing (참고)
요청마다 `X-Timestamp`(ms epoch), `X-API-KEY`, `X-Customer`, `X-Signature` 헤더가 필요하다.
```
message = "{timestamp}.{method}.{uri_path_only}" # 쿼리스트링 제외, method는 대문자
signature = base64(HMAC_SHA256(message, SECRET_KEY))
```
## Output fields
`stats`: `impCnt`(노출수), `clkCnt`(클릭수), `salesAmt`(광고비), `ctr`(CTR, 없으면 클릭수/노출수로 계산), `cpc`(CPC, 없으면 광고비/클릭수로 계산), `avgRnk`(평균노출순위), `ccnt`(전환수). 각 행에 한글 라벨(`labels`)을 덧붙인다.
`keywordtool`: `relKeyword`(연관키워드), `monthlyPcQcCnt`/`monthlyMobileQcCnt`(월간 PC/모바일 조회수), `compIdx`(경쟁정도) 등.
## Scope (읽기 전용 — 이 경계를 넘지 않는다)
허용: `/ncc/campaigns`, `/ncc/adgroups`, `/ncc/keywords`, `/stats`, `/keywordstool` GET 조회.
금지 (구현하지 않음): 입찰가 변경, 캠페인/광고그룹/키워드 생성·수정·삭제, 예산 변경, 상태(운영중지 등) 토글. 돈이 직접 나가는 쓰기 작업과 조회를 안전 경계로 분리하기 위해 의도적으로 뺐다. 쓰기 기능이 필요해지면 별도 스킬/별도 승인 흐름으로 분리한다.
## Failure modes
| 증상 | 원인 |
|---|---|
| `missing required env var(s): ...` | 환경변수 미설정. 정확히 어떤 이름이 빠졌는지 출력됨 |
| HTTP 401 | 서명 실패 — 키 값 또는 시스템 시계 확인 |
| HTTP 403 | 이 `customer_id`에 조회 권한 없음 |
| HTTP 404 | campaign/adgroup id가 잘못됨 |
| HTTP 429 | 호출 한도 초과 — 잠시 후 재시도 |
| `egress unreachable` | 클라우드 샌드박스 등에서 네이버로 나가는 네트워크가 막힘 — 로컬 실행 필요 |
## Done when
- `stats` 명령이 지정한 id·기간에 대해 노출수·클릭수·광고비·CTR·CPC를 반환한다.
- 입찰가/캠페인/키워드를 바꾸는 어떤 요청도 보내지 않았다.
- 키가 없을 때 우회하지 않고 정확한 환경변수 이름을 안내했다.
## Notes
- 대용량 기간 리포트(TSV 비동기 생성, `/stat-reports`)는 이번 v1 범위 밖이다. 필요해지면 별도로 추가한다.
- 참고 구현: [`NariP/naver-searchad`](https://github.com/NariP/naver-searchad) (MIT) — 서명 방식과 명령 설계를 참고했다.
scripts/naver_ad_performance.py
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = "https://api.searchad.naver.com"
API_KEY_ENV = "NAVER_AD_API_KEY"
SECRET_KEY_ENV = "NAVER_AD_SECRET_KEY"
CUSTOMER_ID_ENV = "NAVER_AD_CUSTOMER_ID"
DEFAULT_TIMEOUT = 20
STATS_FIELDS = ["impCnt", "clkCnt", "salesAmt", "ctr", "cpc", "avgRnk", "ccnt"]
STATS_LABELS = {
"impCnt": "노출수",
"clkCnt": "클릭수",
"salesAmt": "광고비",
"ctr": "CTR",
"cpc": "CPC",
"avgRnk": "평균노출순위",
"ccnt": "전환수",
}
KEYWORDTOOL_LABELS = {
"relKeyword": "연관키워드",
"monthlyPcQcCnt": "월간PC조회수",
"monthlyMobileQcCnt": "월간모바일조회수",
"monthlyAvePcClkCnt": "월평균PC클릭수",
"monthlyAveMobileClkCnt": "월평균모바일클릭수",
"compIdx": "경쟁정도",
}
# 이 스킬은 읽기 전용이다. 캠페인/그룹/키워드/입찰가를 바꾸는 write 엔드포인트는
# 의도적으로 구현하지 않는다 (docs 참고: naver-ad-performance/SKILL.md의 "쓰기 금지").
class CredentialError(RuntimeError):
pass
class ApiError(RuntimeError):
def __init__(self, status: int, body: str):
super().__init__(f"HTTP {status}: {body}")
self.status = status
self.body = body
def resolve_credentials() -> tuple[str, str, str]:
api_key = os.getenv(API_KEY_ENV)
secret_key = os.getenv(SECRET_KEY_ENV)
customer_id = os.getenv(CUSTOMER_ID_ENV)
missing = [
name
for name, value in (
(API_KEY_ENV, api_key),
(SECRET_KEY_ENV, secret_key),
(CUSTOMER_ID_ENV, customer_id),
)
if not value
]
if missing:
raise CredentialError(
"missing required env var(s): " + ", ".join(missing) + ". "
"발급: 네이버 검색광고 > 도구 > API 사용 관리."
)
return api_key, secret_key, customer_id
def build_signature(secret_key: str, timestamp: str, method: str, uri: str) -> str:
message = f"{timestamp}.{method}.{uri}".encode("utf-8")
digest = hmac.new(secret_key.encode("utf-8"), message, hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def request(method: str, uri: str, params: dict | None = None, timeout: int = DEFAULT_TIMEOUT):
api_key, secret_key, customer_id = resolve_credentials()
timestamp = str(round(time.time() * 1000))
signature = build_signature(secret_key, timestamp, method, uri)
headers = {
"X-Timestamp": timestamp,
"X-API-KEY": api_key,
"X-Customer": customer_id,
"X-Signature": signature,
"Accept": "application/json",
}
url = BASE_URL + uri
if params:
url += "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise ApiError(exc.code, body) from exc
except urllib.error.URLError as exc:
raise ApiError(0, f"egress unreachable: {exc.reason}") from exc
def with_derived_stats(row: dict) -> dict:
imp = row.get("impCnt") or 0
clk = row.get("clkCnt") or 0
sales = row.get("salesAmt") or 0
if "ctr" not in row:
row["ctr"] = round(clk / imp * 100, 2) if imp else 0
if "cpc" not in row:
row["cpc"] = round(sales / clk, 2) if clk else 0
return row
def label_stats(rows: list[dict]) -> list[dict]:
labeled = []
for row in rows:
row = with_derived_stats(dict(row))
row["labels"] = {field: STATS_LABELS[field] for field in STATS_FIELDS if field in row}
labeled.append(row)
return labeled
def label_keywordtool(rows: list[dict]) -> list[dict]:
labeled = []
for row in rows:
row = dict(row)
row["labels"] = {field: label for field, label in KEYWORDTOOL_LABELS.items() if field in row}
labeled.append(row)
return labeled
def cmd_doctor(_args: argparse.Namespace) -> dict:
result = {"env": {}, "reachable": None}
for name in (API_KEY_ENV, SECRET_KEY_ENV, CUSTOMER_ID_ENV):
result["env"][name] = bool(os.getenv(name))
try:
req = urllib.request.Request(BASE_URL, method="GET")
urllib.request.urlopen(req, timeout=8).close()
result["reachable"] = True
except urllib.error.HTTPError:
result["reachable"] = True # HTTP 응답을 받았다는 것 자체가 egress는 열려있다는 뜻
except urllib.error.URLError as exc:
result["reachable"] = False
result["reachable_error"] = str(exc.reason)
return result
def cmd_campaigns(_args: argparse.Namespace) -> list[dict]:
return request("GET", "/ncc/campaigns")
def cmd_adgroups(args: argparse.Namespace) -> list[dict]:
return request("GET", "/ncc/adgroups", {"nccCampaignId": args.campaign})
def cmd_keywords(args: argparse.Namespace) -> list[dict]:
return request("GET", "/ncc/keywords", {"nccAdgroupId": args.adgroup})
def cmd_stats(args: argparse.Namespace) -> list[dict]:
ids = [i.strip() for i in args.ids.split(",") if i.strip()]
params = {
"ids": json.dumps(ids),
"fields": json.dumps(STATS_FIELDS),
}
if args.since or args.until:
if not (args.since and args.until):
raise CredentialError("--since and --until must be given together")
params["timeRange"] = json.dumps({"since": args.since, "until": args.until})
if args.by:
params["breakdown"] = args.by
data = request("GET", "/stats", params)
rows = data.get("data", data) if isinstance(data, dict) else data
return label_stats(rows)
def cmd_keywordtool(args: argparse.Namespace) -> list[dict]:
params = {"hintKeywords": args.keywords, "showDetail": 1}
data = request("GET", "/keywordstool", params)
rows = data.get("keywordList", data) if isinstance(data, dict) else data
return label_keywordtool(rows)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="네이버 검색광고 성과 조회 (읽기 전용)")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("doctor", help="키 존재 여부와 네이버 도달성 점검").set_defaults(func=cmd_doctor)
sub.add_parser("campaigns", help="캠페인 목록").set_defaults(func=cmd_campaigns)
ag = sub.add_parser("adgroups", help="광고그룹 목록")
ag.add_argument("--campaign", required=True, help="nccCampaignId")
ag.set_defaults(func=cmd_adgroups)
kw = sub.add_parser("keywords", help="키워드 목록")
kw.add_argument("--adgroup", required=True, help="nccAdgroupId")
kw.set_defaults(func=cmd_keywords)
st = sub.add_parser("stats", help="성과 조회 (노출수·클릭수·광고비·CTR·CPC·평균순위·전환수)")
st.add_argument("--ids", required=True, help="캠페인/그룹/키워드 id, 콤마구분")
st.add_argument("--since", help="YYYY-MM-DD")
st.add_argument("--until", help="YYYY-MM-DD")
st.add_argument("--by", choices=["day"], help="breakdown 단위")
st.set_defaults(func=cmd_stats)
kt = sub.add_parser("keywordtool", help="연관키워드·월간 조회수·경쟁정도")
kt.add_argument("--keywords", required=True, help="힌트 키워드, 콤마구분")
kt.set_defaults(func=cmd_keywordtool)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
result = args.func(args)
except CredentialError as exc:
print(str(exc), file=sys.stderr)
return 1
except ApiError as exc:
hints = {
401: "서명 실패 — 키가 맞는지 / 시스템 시계가 정확한지 확인",
403: "권한 없음 — 이 customer_id에 조회 권한이 있는지 확인",
404: "id를 찾을 수 없음 — campaign/adgroup id 확인",
429: "호출 한도 초과 — 잠시 후 재시도",
0: "egress 차단 — 로컬 환경(클로드 코드/코덱스)에서 실행했는지 확인",
}
hint = hints.get(exc.status, "")
print(f"{exc}" + (f" ({hint})" if hint else ""), file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: naver-ad-performance
description: 네이버 검색광고(키워드광고)의 노출수·클릭수·광고비·CTR·CPC·전환수를 조회한다. 캠페인/광고그룹/키워드 목록, 기간별 성과, 연관키워드·월간 조회수 도구 포함. 읽기 전용, 로컬 실행 전용. 돌쇠에서는 공식 표면을 통한 후속 액션까지 진행한다.
license: MIT
metadata:
category: marketing
locale: ko-KR
phase: v1
---
# naver-ad-performance
<!-- k-skill:cli-stub — generated by scripts/generate-skill-stubs.js; edit skill.json / instruction.md instead -->
## Get the full instructions (required first step)
Run this and follow its output as the primary instructions for this skill:
```bash
npx -y @nomadamas/k-skill@0 instruct naver-ad-performance
```
The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by:
```bash
npx -y @nomadamas/k-skill@0 files naver-ad-performance
```
Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with:
```bash
npx -y @nomadamas/k-skill@0 update
```
If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/naver-ad-performance/instruction.md.
## Legal disclaimer (required)
This skill is not an official feature of, officially supported by, affiliated with, sponsored by, approved by, or developed in collaboration with any third-party trademark owner or service operator it identifies. Third-party names are used only to describe the skill's function, lookup target, or compatibility.
Any automated collection of publicly accessible information must be limited to personal, non-organizational lookup. Do not use this skill for systematic or bulk crawling, database building, access-control or block circumvention, or conduct that interferes with a third party's business or service.
Read the full Korean legal disclaimer, including the cited Korean Supreme Court precedents and statutory limits, before use:
```bash
npx -y @nomadamas/k-skill@0 read naver-ad-performance references/DISCLAIMER.md
```
## Hard rules even without the CLI
- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand.
- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments.
- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries.