수완뉴스 로봇 기자 · REST API

수완뉴스 로봇 기자 API

워드프레스가 아닌 CMS에서 로봇 기자 기사를 받아 발행하기 위한 REST API. 날씨·특보부터 증권 공시까지, 로봇 기자 모듈 9종이 만든 기사를 어떤 CMS에서든 받아 발행할 수 있습니다.

v1.0.0-draft · 구현 전 초안 OpenAPI 3.1.0 문의 [email protected]
  • https://api.swnest.net/robot/v1 운영 (예정)
  • https://sandbox.api.swnest.net/robot/v1 샌드박스 (예정) — 예시 기사만 내려주며 발행 한도를 차감하지 않습니다.

연동 방식

세 방식 모두 같은 Article 객체를 주고받습니다. CMS 사정에 맞게 하나를 고르거나 섞어 쓰세요.

풀 (Pull)

CMS가 1분 이상 간격으로 GET /articles?updated_after=…를 호출합니다. 방화벽 안쪽 CMS나 배치 작업에 맞습니다.

이벤트 (Webhook)

발행·교정·격리 순간 CMS URL로 robotEvent를 받습니다. 지연 없이 반영하고 싶을 때 씁니다.

푸시 (Push)

CMS가 PUT …/{article_id} 한 개를 구현하면 로봇 기자가 직접 넣고 고칩니다. 지금의 워드프레스 연동과 같은 구조입니다.

sequenceDiagram
  participant R as 로봇 기자
  participant D as AI 데스크
  participant C as CMS
  R->>C: article.published (v1)
  C-->>R: PUT /delivery (published, cms_url)
  D->>R: 점검 · 교정
  R->>C: article.updated (v2)
  D->>R: 사실관계 의심 → 격리
  R->>C: article.withdrawn (v3)
  Note over C: 비공개로 전환, 삭제하지 않음
  

빠른 시작

풀 방식

shell
# 1) 마지막으로 받은 updated_at 이후 바뀐 기사 가져오기
curl -s "https://api.swnest.net/robot/v1/articles?module=weather,quake&updated_after=2026-10-25T09:00:00%2B09:00&limit=100" \
  -H "Authorization: Bearer $ROBOT_API_KEY"

# 2) 기사마다: id로 CMS 글을 찾아 version이 더 높으면 덮어쓰기
#    status=published → 공개 / held → 초안 / withdrawn → 비공개

# 3) 처리 결과 보고 (선택이지만 권장)
curl -s -X PUT "https://api.swnest.net/robot/v1/articles/art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1/delivery" \
  -H "Authorization: Bearer $ROBOT_API_KEY" -H "Content-Type: application/json" \
  -d '{"version":2,"state":"published","cms_id":"48213","cms_url":"https://news.example.com/articles/48213"}'

# 4) next_cursor가 null이 될 때까지 반복, 마지막 updated_at 저장

이벤트 방식 — 웹훅 등록

shell
curl -s -X POST "https://api.swnest.net/robot/v1/webhooks" \
  -H "Authorization: Bearer $ROBOT_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: register-hook-1" \
  -d '{"url":"https://cms.example.com/hooks/robot","mode":"events",
       "events":["article.published","article.updated","article.withdrawn","module.stalled"]}'
# → 응답의 secret(whsec_…)을 안전한 곳에 저장. 다시 조회할 수 없습니다.

응답 예시 — Article

GET /articles/art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1
{
  "id": "art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1",
  "version": 2,
  "module": "weather",
  "dedupe_key": "weather:202610230400:L1020100",
  "status": "published",
  "title": "\"남해동부안쪽먼바다, 풍랑주의보 발표\"… 상황 변화 주시",
  "summary": "남해동부안쪽먼바다에 풍랑주의보가 발표됐습니다. 유의파고가 3시간 전보다 1.5m 높아졌습니다.",
  "body_html": "<p><strong>[수완뉴스 로봇 기자] 2026-10-23 04:00 발표</strong></p><p>남해동부안쪽먼바다에…</p>",
  "byline": "수완뉴스 로봇 기자",
  "category_hint": "날씨 예보",
  "tags": [
    "풍랑주의보",
    "남해동부"
  ],
  "region": {
    "code": "L1020100",
    "name": "남해동부안쪽먼바다",
    "sido_code": "48"
  },
  "media": [
    {
      "id": "med_01JBF2XA",
      "role": "featured",
      "url": "https://media.swnest.net/robot/weather/202610230400-L1020100.png",
      "mime_type": "image/png",
      "width": 1200,
      "height": 900,
      "alt": "남해동부 특보 구역 지도",
      "credit": "기상청"
    }
  ],
  "sources": [
    {
      "name": "기상청 기상특보",
      "url": "https://www.weather.go.kr/",
      "retrieved_at": "2026-10-23T04:00:41+09:00"
    }
  ],
  "event_at": "2026-10-23T04:00:00+09:00",
  "data_as_of": "2026-10-23T04:00:00+09:00",
  "created_at": "2026-10-23T04:01:12+09:00",
  "updated_at": "2026-10-23T06:15:03+09:00",
  "published_at": "2026-10-23T04:01:12+09:00",
  "review": {
    "state": "corrected",
    "checked_at": "2026-10-23T06:15:03+09:00",
    "desk": "weather",
    "reasons": [
      "제목 특보 종류 오기"
    ]
  },
  "delivery": {
    "version": 1,
    "state": "published",
    "cms_id": "48213",
    "cms_url": "https://news.example.com/articles/48213",
    "delivered_at": "2026-10-23T04:01:30+09:00"
  }
}

위 기사는 교정본(version: 2)인데 CMS는 아직 1판을 발행한 상태(delivery.version: 1)입니다. 이럴 때 CMS는 2판으로 덮어써야 합니다.

인증과 서명

Authorization: Bearer rr_live_… 헤더로 보냅니다. 키마다 권한 범위를 정할 수 있습니다: articles:read(조회), articles:write(발행 결과 보고), webhooks:manage(웹훅 관리). rr_test_ 키는 샌드박스에서만 동작합니다.

웹훅 서명 검증

t={unix초},v1={hex} 형식. v1 = HMAC-SHA256(secret, "{t}.{요청 본문 원문}"). t가 현재 시각과 5분 넘게 차이 나면 거부하세요. 비밀값 교체 기간에는 v1이 두 개 올 수 있습니다.

PHP
<?php
function verify_robot_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
    $t = null; $sigs = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        if ($k === 't') $t = (int) $v;
        if ($k === 'v1') $sigs[] = $v;
    }
    if (!$t || abs(time() - $t) > $tolerance) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $s) if (hash_equals($expected, $s)) return true;
    return false;
}
// $ok = verify_robot_signature(file_get_contents('php://input'), $_SERVER['HTTP_X_ROBOT_SIGNATURE'] ?? '', ROBOT_WEBHOOK_SECRET);

공통 규칙

  • 시각은 모두 RFC 3339, 한국 시간(+09:00)으로 표기합니다.
  • 오류 응답은 application/problem+json(RFC 9457) 형식입니다.
  • 목록은 커서 방식으로 넘깁니다. 응답의 next_cursor를 다음 요청의 cursor로 보내고, null이면 끝입니다.
  • 요청 한도: API 키당 분당 600회. 모든 응답에 RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset 헤더가 붙습니다.
  • 하위 호환을 깨는 변경은 /v2로 냅니다. v1 안에서는 필드 추가만 하므로, 모르는 필드는 무시하도록 구현하세요.

CMS 구현 체크리스트

  • id로 글을 찾고, 없을 때만 새로 만듭니다.
  • 받은 version이 가진 것보다 낮으면 무시합니다.
  • held는 초안, withdrawn은 비공개. 어느 쪽도 삭제하지 않습니다.
  • review.state: corrected면 기사 하단에 정정 표기를 붙이는 것을 권장합니다 (/versions의 note).
  • sources를 기사 하단 출처로 표기합니다.
  • module.stalled 이벤트는 편집국 알림 채널로 보냅니다. 모듈의 schedule 밖에서는 오지 않습니다.

모듈과 중복 기준

로봇 기자는 아래 기준으로 만든 dedupe_key가 같으면 기사를 다시 만들지 않습니다. 같은 사건의 갱신은 새 기사 대신 기존 기사의 새 version으로 옵니다.

키모듈중복 기준 (dedupe_key)
weather날씨·기상특보특보 발표 시각 + 특보 구역
quake지진 알림기상청 지진 통보 번호
culture문화 생활행사 ID + 시작일
trend인터넷 핫이슈키워드 + 시간대(1시간)
policy정책 브리핑보도자료 ID
auction법원 경매 정보사건번호 + 매각기일
stock증권 종목 현황종목코드 + 거래일 + 회차
disclosure기업 공시전자공시 접수번호
marketAI 시황 (장전·마감)거래일 + 장전/마감

워드프레스 연동과의 대응

현재 lab.swnest.net 워드프레스에 쓰는 방식을 이 API의 개념으로 옮기면 다음과 같습니다.

워드프레스 (현재)로봇 기자 API비고
기사 IDid (art_…)WP 글 ID와 별개. CMS 쪽 중복 방지 키
슬러그 rnn-wrn-202610230400-…, kma-…dedupe_key봇이 같은 사건을 두 번 쓰지 않게 하는 키
publishstatus: published
draft (검수 대기 모드, 한도 초과)status: held
검수필요 태그 + 초안 전환status: withdrawn, review.state: flaggedAI 데스크 격리
AI검수완료 태그review.state: corrected수정본은 version 증가
WP 리비전GET /articles/{id}/versions정정 사유 note 포함
본문 첫 이미지(썸네일 폴백)media[role=featured]대표 이미지를 명시적으로 지정
카테고리 ID (9, 16 …)module + category_hintCMS에서 모듈별로 카테고리 매핑

모듈

구독 중인 로봇 기자 모듈과 발행 일정

GET/modules

모듈 목록

API 키 · articles:read

전체 모듈과 현재 요금제에서 쓸 수 있는지(enabled)를 돌려줍니다.

응답
  • 200 모듈 목록 application/json object
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem

기사

로봇 기자가 만든 기사 조회와 CMS 발행 결과 보고

GET/articles

기사 목록

API 키 · articles:read

updated_after를 마지막으로 받은 기사의 updated_at으로 두고 반복 호출하면 새 기사와 수정본을 빠짐없이 받을 수 있습니다. 정렬은 항상 updated_at 오름차순입니다. 1분보다 짧은 간격으로 호출하지 마세요.

파라미터
이름위치타입설명
modulequerystring

모듈 키. 쉼표로 여러 개 지정할 수 있습니다.

statusquerystring

기사 상태. 쉼표로 여러 개 지정할 수 있습니다. 생략하면 전체입니다.

updated_afterquerystring date-time

이 시각 이후 생성·수정된 기사만 (해당 시각 미포함)

event_afterquerystring date-time

사건 기준 시각(event_at, 예 특보 발표 시각)이 이 시각 이후인 기사만

regionquerystring

지역 코드(행정표준코드 앞 2자리 시도 코드 또는 기상청 특보 구역 코드)

cursorquerystring

이전 응답의 next_cursor

limitqueryinteger
응답
  • 200 기사 목록 application/json object
  • 400 요청 형식 오류 application/problem+json Problem
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
  • 429 요청 한도 초과. Retry-After 초만큼 기다린 뒤 다시 시도하세요. application/problem+json Problem
GET/articles/{article_id}

기사 한 건

API 키 · articles:read

항상 최신 버전을 돌려줍니다. 응답의 ETag를 If-None-Match로 보내면 바뀌지 않았을 때 304를 받습니다.

파라미터
이름위치타입설명
article_id필수pathstring
응답
  • 200 기사 application/json Article
  • 304 마지막으로 받은 버전에서 바뀌지 않음
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
  • 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
GET/articles/{article_id}/versions

기사 수정 이력

API 키 · articles:read

AI 데스크 교정·격리 이력을 버전별로 돌려줍니다. 정정 보도 표기를 할 때 씁니다.

파라미터
이름위치타입설명
article_id필수pathstring
응답
  • 200 버전 목록 (오래된 순) application/json object
  • 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
PUT/articles/{article_id}/delivery

CMS 발행 결과 보고

API 키 · articles:write

CMS가 기사를 실제로 어떻게 처리했는지 알려줍니다. 같은 내용으로 여러 번 보내도 결과는 같습니다. 보고된 cms_url은 월간 품질 리포트와 AI 데스크 점검(공개 페이지 확인)에 쓰입니다. 풀·이벤트 방식에서는 보고를 권장하고, 푸시 방식에서는 articleUpsert 응답으로 대신합니다.

파라미터
이름위치타입설명
article_id필수pathstring
요청 본문
application/json Delivery
응답
  • 200 기록됨 application/json Delivery
  • 400 요청 형식 오류 application/problem+json Problem
  • 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
  • 409 보고한 version보다 새 버전이 이미 있음. 최신 버전을 다시 받아 처리하세요. application/problem+json Problem

웹훅

기사 이벤트를 받을 URL 관리

GET/webhooks

웹훅 목록

API 키 · webhooks:manage

응답
  • 200 등록된 웹훅 application/json object
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
POST/webhooks

웹훅 등록

API 키 · webhooks:manage

응답의 secret은 이때 한 번만 보여줍니다. 이 값으로 X-Robot-Signature를 검증하세요. mode: push로 등록하면 이벤트 대신 articleUpsert 계약으로 기사를 직접 보냅니다.

파라미터
이름위치타입설명
Idempotency-Keyheaderstring

같은 키로 24시간 안에 다시 보내면 처음 결과를 그대로 돌려줍니다.

요청 본문
application/json WebhookCreate
응답
  • 201 등록됨 application/json Webhook + object
  • 400 요청 형식 오류 application/problem+json Problem
DELETE/webhooks/{webhook_id}

웹훅 삭제

API 키 · webhooks:manage

파라미터
이름위치타입설명
webhook_id필수pathstring
응답
  • 204 삭제됨
  • 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
POST/webhooks/{webhook_id}/test

시험 이벤트 보내기

API 키 · webhooks:manage

샌드박스 기사로 article.published 이벤트(또는 push 모드면 articleUpsert)를 한 번 보내고 결과를 돌려줍니다.

파라미터
이름위치타입설명
webhook_id필수pathstring
응답
  • 200 전송 결과 application/json object
  • 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem

피드

API를 직접 호출하기 어려운 CMS를 위한 읽기 전용 피드

GET/feeds/{module}.json

JSON Feed

인증 없음 (피드 토큰)

JSON Feed 1.1 형식으로 최근 기사 50건을 돌려줍니다. _robot 확장 필드에 article_id, version, status, dedupe_key가 들어 있습니다. 격리된 기사는 피드에서 빠지므로, 격리를 반영해야 하는 CMS는 API나 웹훅을 쓰세요.

파라미터
이름위치타입설명
module필수pathModuleKey | all

모듈 키 또는 all

token필수querystring

피드 전용 읽기 토큰 (API 키와 별개로 발급, URL에 넣어도 되는 범위만 가짐)

응답
  • 200 JSON Feed application/feed+json object
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
GET/feeds/{module}.rss

RSS 2.0

인증 없음 (피드 토큰)

RSS 2.0 형식으로 최근 기사 50건을 돌려줍니다. <guid>는 article_id이고 본문은 <content:encoded>에 들어 있습니다.

파라미터
이름위치타입설명
module필수pathModuleKey | all

모듈 키 또는 all

token필수querystring

피드 전용 읽기 토큰 (API 키와 별개로 발급, URL에 넣어도 되는 범위만 가짐)

응답
  • 200 RSS application/rss+xml string
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem

사용량

요금제 한도와 사용량

GET/usage

오늘 사용량과 한도

API 키 · articles:read

응답
  • 200 사용량 application/json Usage
  • 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem

웹훅 (로봇 기자 → CMS)

로봇 기자가 CMS로 보내는 요청입니다. 두 번째(articleUpsert)는 push 모드를 쓰는 CMS가 직접 구현해야 하는 계약입니다.

POST{등록한 url}

기사 이벤트 (로봇 기자 → CMS)

로봇 기자 → CMS · events 모드

mode: events로 등록한 URL에 보냅니다. CMS는 10초 안에 2xx로 응답해야 하며, 그렇지 않으면 1분·5분·30분·2시간·6시간·24시간 간격으로 재시도합니다. 같은 이벤트가 두 번 올 수 있으니 id로 중복을 거르세요. 순서가 뒤바뀌어 도착할 수 있으므로, 이미 가진 기사보다 data.article.version이 낮으면 무시하세요.

파라미터
이름위치타입설명
X-Robot-Signature필수headerstring

t={unix초},v1={hex} 형식. v1 = HMAC-SHA256(secret, "{t}.{요청 본문 원문}"). t가 현재 시각과 5분 넘게 차이 나면 거부하세요. 비밀값 교체 기간에는 v1이 두 개 올 수 있습니다.

X-Robot-Event필수headerEventType
요청 본문
application/json Event
응답
  • 2XX 수신 확인. 응답 본문은 무시합니다.
PUT{등록한 url}/{article_id}

기사 넣기·고치기 (로봇 기자 → CMS, push 모드)

로봇 기자 → CMS · push 모드 · CMS가 구현

mode: push로 등록한 CMS가 구현하는 계약입니다. 로봇 기자는 {등록한 url}/{article_id}로 PUT합니다.

  • 처음 받은 article_id면 새 글을 만들고, 이미 있으면 덮어씁니다. 같은 요청이 다시 와도 글이 하나만 있어야 합니다.
  • status가 withdrawn이면 글을 비공개로 돌립니다. 삭제하지 마세요.
  • status가 held이면 초안(편집자 승인 대기)으로 둡니다.
  • 이미지는 media[].url에서 받아 CMS에 다시 올려도 되고, 그대로 링크해도 됩니다.

재시도 규칙은 robotEvent와 같습니다.

파라미터
이름위치타입설명
X-Robot-Article-Id필수headerstring

URL 끝의 {article_id}와 같은 값 (프록시가 경로를 바꾸는 환경용)

X-Robot-Signature필수headerstring

t={unix초},v1={hex} 형식. v1 = HMAC-SHA256(secret, "{t}.{요청 본문 원문}"). t가 현재 시각과 5분 넘게 차이 나면 거부하세요. 비밀값 교체 기간에는 v1이 두 개 올 수 있습니다.

요청 본문
application/json Article
응답
  • 200 처리 결과. 로봇 기자는 이 값을 발행 결과 보고로 기록합니다. application/json Delivery
  • 409 CMS에 더 새로운 버전이 이미 있음. 로봇 기자는 재시도하지 않습니다.

스키마

ModuleKey

키모듈중복 기준 (dedupe_key)
weather날씨·기상특보특보 발표 시각 + 특보 구역
quake지진 알림기상청 지진 통보 번호
culture문화 생활행사 ID + 시작일
trend인터넷 핫이슈키워드 + 시간대(1시간)
policy정책 브리핑보도자료 ID
auction법원 경매 정보사건번호 + 매각기일
stock증권 종목 현황종목코드 + 거래일 + 회차
disclosure기업 공시전자공시 접수번호
marketAI 시황 (장전·마감)거래일 + 장전/마감

Module

필드타입설명
key필수ModuleKey
name필수string

예: 날씨·기상특보

tier필수string lite · standard · pro

이 모듈이 처음 포함되는 요금제

enabled필수boolean

현재 요금제(또는 단품 추가)로 쓸 수 있는지

scheduleobject

발행 일정. 이 시간 밖의 무발행은 장애가 아닙니다.

필드타입설명
daysstring mon · tue · wed · thu · fri · sat · sun[]
trading_days_onlyboolean

증권 모듈처럼 장 운영일에만 발행하면 true

fixed_timesstring[]
last_published_atstring | null date-time
healthstring ok · stalled · off_schedule

stalled: 일정 안인데 기대 주기보다 오래 발행이 없음

Article

필드타입설명
id필수string

기사 고유 ID. CMS 쪽 중복 방지 키로 쓰세요.

예: art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1

version필수integer

수정될 때마다 1씩 오릅니다.

예: 2

module필수ModuleKey
dedupe_key필수string

같은 사건을 가리키는 키. 로봇 기자는 같은 키로 기사를 두 번 만들지 않습니다.

예: weather:202610230400:L1020100

status필수ArticleStatus
title필수string

예: "남해동부안쪽먼바다, 풍랑주의보 발표"… 상황 변화 주시

subtitlestring | null
summarystring | null

CODA 요약 엔진이 본문에서 뽑은 2~3문장 (프로 이상). 본문에 없는 내용은 들어가지 않습니다.

body_html필수string

본문 HTML. 허용 태그: p, h2, h3, strong, em, a, ul, ol, li, table, thead, tbody, tr, th, td, figure, img, figcaption, blockquote, small. 스크립트·iframe·인라인 이벤트 속성은 들어가지 않습니다. 인라인 style은 표와 강조 상자에만 씁니다.

body_textstring

태그를 뺀 본문 (검색 색인·SNS 미리보기용)

bylinestring

예: 수완뉴스 로봇 기자

category_hintstring

CMS 카테고리 연결용 힌트. 실제 카테고리는 CMS 설정에서 모듈별로 매핑하세요.

예: 날씨 예보

tagsstring[]
regionobject | null
entitiesobject[]

기사에 나온 종목·기관 등 (증권·공시 모듈)

필드타입설명
typestring stock · company · agency · person · place
idstring

예: 005930

namestring

예: 삼성전자

mediaMedia[]
sourcesobject[]

근거 데이터 출처. 기사 하단 출처 표기에 쓰세요.

필드타입설명
name필수string

예: 기상청 기상특보

urlstring | null uri
retrieved_atstring date-time
event_atstring | null date-time

사건 기준 시각 (특보 발표·지진 발생·공시 접수·장 마감 등)

예: 2026-10-23T04:00:00+09:00

data_as_ofstring | null date-time

본문 수치의 기준 시각. 기사 상단에 "○시 기준"으로 표기하세요.

created_at필수string date-time
updated_at필수string date-time
published_atstring | null date-time

로봇 기자가 공개 가능으로 판정한 시각

reviewReview
deliveryDelivery | null

이 API 키의 CMS가 마지막으로 보고한 발행 결과

ArticleStatus

  • published — 공개해도 되는 기사
  • held — 검수 대기. 초안으로 두고 편집자 승인 후 공개하세요 (검수 대기 모드, 일일 한도 초과분)
  • withdrawn — 격리. 이미 공개했다면 비공개로 돌리세요. 삭제하지는 마세요.

Review

AI 데스크 점검 결과

필드타입설명
state필수string unchecked · passed · corrected · flagged
  • unchecked — 아직 점검 전 (발행 후 최대 3시간)
  • passed — 이상 없음
  • corrected — 경미한 오류를 고침 (version 증가, 수정 내용은 /versions)
  • flagged — 사실관계 확인 필요. 보통 status: withdrawn과 함께 옵니다.
checked_atstring | null date-time
deskstring | null market · weather · trend
reasonsstring[]

ArticleVersion

필드타입설명
version필수integer
created_at필수string date-time
change필수string created · corrected · held · withdrawn · restored
changed_fieldsstring[]
notestring | null

정정 사유 (정정 보도 문구로 써도 되는 수준으로 작성됨)

예: 제목의 특보 종류를 '강풍주의보'에서 '풍랑주의보'로 바로잡음

Media

필드타입설명
id필수string

예: med_01JBF2XA

role필수string featured · inline

featured는 대표 이미지(기사당 최대 1개). inline은 본문 <img>에도 같은 URL로 들어 있습니다.

url필수string uri

최소 90일 유지됩니다. 장기 보관이 필요하면 CMS에 다시 올리세요.

mime_typestring

예: image/png

widthinteger

예: 1200

heightinteger

예: 900

altstring

예: 남해동부 특보 구역 지도

captionstring | null
creditstring

예: 기상청

Delivery

필드타입설명
version필수integer

CMS가 처리한 기사 버전

state필수string published · draft · unpublished · rejected

CMS에서의 실제 상태

cms_idstring | null

예: 48213

cms_urlstring | null uri

예: https://news.example.com/articles/48213

reasonstring | null

rejected일 때 사유

delivered_atstring date-time

EventType

  • article.published — 새 기사가 공개 가능해짐
  • article.updated — 공개된 기사가 교정됨 (version 증가)
  • article.held — 검수 대기로 들어감
  • article.withdrawn — 격리됨. 비공개로 돌리세요.
  • module.stalled — 일정 안인데 모듈 발행이 멈춤 (data.module)
  • quota.reached — 오늘 발행 한도 도달 (data.usage). 이후 기사는 held로 옵니다.

Event

필드타입설명
id필수string

예: evt_01JBF31H

type필수EventType
created_at필수string date-time
data필수object
필드타입설명
articleArticle
moduleModule
usageUsage

Webhook

필드타입설명
id필수string

예: wh_01JBF3Q8

url필수string uri

예: https://cms.example.com/hooks/robot

mode필수string events · push

events는 robotEvent, push는 articleUpsert 계약으로 보냅니다.

events필수EventType[]
modulesModuleKey[]

비우면 구독 중인 모든 모듈

active필수boolean
last_deliveryobject | null

WebhookCreate

필드타입설명
url필수string uri

HTTPS만 허용

mode필수string events · push
eventsEventType[]

생략하면 전체 이벤트

modulesModuleKey[]

Usage

필드타입설명
plan필수string lite · standard · pro · enterprise
date필수string date

예: 2026-10-25

articles_today필수integer

예: 312

daily_limit필수integer | null

null이면 제한 없음 (엔터프라이즈)

예: 500

held_over_limitinteger

오늘 한도를 넘겨 held로 보낸 기사 수

sites_usedinteger

예: 2

sites_limitinteger | null

예: 3

Problem

필드타입설명
type필수string uri

예: https://api.swnest.net/robot/problems/invalid-parameter

title필수string

예: 잘못된 요청 값

status필수integer

예: 400

detailstring

예: limit은 1~100 사이여야 합니다.

codestring invalid_parameter · unauthorized · forbidden_scope · not_found · module_not_in_plan · version_conflict · rate_limited · internal_error
request_idstring

예: req_7c1d0e

원본 명세

아래 YAML을 Swagger UI, Redoc, Postman, openapi-generator 등에 그대로 넣어 클라이언트·서버 코드를 만들 수 있습니다.

openapi.yaml 펼치기
openapi.yaml · OpenAPI 3.1.0
openapi: 3.1.0
info:
  title: 수완뉴스 로봇 기자 API
  version: 1.0.0-draft
  summary: 워드프레스가 아닌 CMS에서 로봇 기자 기사를 받아 발행하기 위한 REST API
  description: |
    로봇 기자 모듈(날씨·특보, 지진, 문화, 핫이슈, 정책, 경매, 증권 종목, 기업 공시, AI 시황)이 만든 기사를
    어떤 CMS에서든 받아 쓸 수 있게 하는 API 명세입니다. **v1 초안이며 아직 구현되지 않았습니다.**

    ## 연동 방식 세 가지
    1. **풀(Pull)** — CMS가 `GET /articles`를 주기적으로 호출해 `updated_after` 이후 바뀐 기사를 가져갑니다.
    2. **이벤트(Webhook)** — 기사가 발행·수정·격리될 때 CMS가 등록한 URL로 `robotEvent`를 보냅니다.
       CMS는 이벤트를 받은 뒤 `GET /articles/{article_id}`로 본문을 가져가도 되고, 이벤트에 실린 기사를 그대로 써도 됩니다.
    3. **푸시(Push)** — CMS가 `articleUpsert` 계약을 구현하면 로봇 기자가 기사를 CMS에 직접 넣습니다
       (지금 워드프레스 연동과 같은 방식).

    세 방식 모두 같은 `Article` 객체를 씁니다. 같은 `id`에 대해 `version`이 올라가면 같은 기사의 수정본이므로
    새 글을 만들지 말고 덮어써야 합니다.

    ## 공통 규칙
    - 시각은 모두 RFC 3339, 한국 시간(`+09:00`)으로 표기합니다.
    - 오류 응답은 `application/problem+json`(RFC 9457) 형식입니다.
    - 목록은 커서 방식으로 넘깁니다. 응답의 `next_cursor`를 다음 요청의 `cursor`로 보내고, `null`이면 끝입니다.
    - 요청 한도: API 키당 분당 600회. 모든 응답에 `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` 헤더가 붙습니다.
    - 하위 호환을 깨는 변경은 `/v2`로 냅니다. v1 안에서는 필드 추가만 하므로, 모르는 필드는 무시하도록 구현하세요.
  license:
    name: Proprietary — 계약 고객 전용
  contact:
    name: 수완뉴스 로봇 기자
    email: [email protected]
servers:
  - url: https://api.swnest.net/robot/v1
    description: 운영 (예정)
  - url: https://sandbox.api.swnest.net/robot/v1
    description: 샌드박스 (예정) — 예시 기사만 내려주며 발행 한도를 차감하지 않습니다.

security:
  - apiKey: []

tags:
  - name: 모듈
    description: 구독 중인 로봇 기자 모듈과 발행 일정
  - name: 기사
    description: 로봇 기자가 만든 기사 조회와 CMS 발행 결과 보고
  - name: 웹훅
    description: 기사 이벤트를 받을 URL 관리
  - name: 피드
    description: API를 직접 호출하기 어려운 CMS를 위한 읽기 전용 피드
  - name: 사용량
    description: 요금제 한도와 사용량

paths:
  /modules:
    get:
      tags: [모듈]
      operationId: listModules
      summary: 모듈 목록
      description: 전체 모듈과 현재 요금제에서 쓸 수 있는지(`enabled`)를 돌려줍니다.
      security:
        - apiKey: [articles:read]
      responses:
        "200":
          description: 모듈 목록
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Module" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /articles:
    get:
      tags: [기사]
      operationId: listArticles
      summary: 기사 목록
      description: |
        `updated_after`를 마지막으로 받은 기사의 `updated_at`으로 두고 반복 호출하면 새 기사와 수정본을 빠짐없이 받을 수 있습니다.
        정렬은 항상 `updated_at` 오름차순입니다. 1분보다 짧은 간격으로 호출하지 마세요.
      security:
        - apiKey: [articles:read]
      parameters:
        - name: module
          in: query
          description: 모듈 키. 쉼표로 여러 개 지정할 수 있습니다.
          schema: { type: string, example: "weather,quake" }
        - name: status
          in: query
          description: 기사 상태. 쉼표로 여러 개 지정할 수 있습니다. 생략하면 전체입니다.
          schema: { type: string, example: "published,withdrawn" }
        - name: updated_after
          in: query
          description: 이 시각 이후 생성·수정된 기사만 (해당 시각 미포함)
          schema: { type: string, format: date-time, example: "2026-10-25T09:00:00+09:00" }
        - name: event_after
          in: query
          description: 사건 기준 시각(`event_at`, 예 특보 발표 시각)이 이 시각 이후인 기사만
          schema: { type: string, format: date-time }
        - name: region
          in: query
          description: 지역 코드(행정표준코드 앞 2자리 시도 코드 또는 기상청 특보 구역 코드)
          schema: { type: string, example: "11" }
        - { $ref: "#/components/parameters/Cursor" }
        - { $ref: "#/components/parameters/Limit" }
      responses:
        "200":
          description: 기사 목록
          headers:
            RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Article" }
                  next_cursor:
                    type: [string, "null"]
                    description: 다음 페이지 커서. `null`이면 마지막 페이지입니다.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }

  /articles/{article_id}:
    parameters:
      - { $ref: "#/components/parameters/ArticleId" }
    get:
      tags: [기사]
      operationId: getArticle
      summary: 기사 한 건
      description: 항상 최신 버전을 돌려줍니다. 응답의 `ETag`를 `If-None-Match`로 보내면 바뀌지 않았을 때 304를 받습니다.
      security:
        - apiKey: [articles:read]
      responses:
        "200":
          description: 기사
          headers:
            ETag:
              description: 기사 버전 태그 (`"art_…:v{version}"`)
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Article" }
        "304":
          description: 마지막으로 받은 버전에서 바뀌지 않음
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /articles/{article_id}/versions:
    parameters:
      - { $ref: "#/components/parameters/ArticleId" }
    get:
      tags: [기사]
      operationId: listArticleVersions
      summary: 기사 수정 이력
      description: AI 데스크 교정·격리 이력을 버전별로 돌려줍니다. 정정 보도 표기를 할 때 씁니다.
      security:
        - apiKey: [articles:read]
      responses:
        "200":
          description: 버전 목록 (오래된 순)
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ArticleVersion" }
        "404": { $ref: "#/components/responses/NotFound" }

  /articles/{article_id}/delivery:
    parameters:
      - { $ref: "#/components/parameters/ArticleId" }
    put:
      tags: [기사]
      operationId: reportDelivery
      summary: CMS 발행 결과 보고
      description: |
        CMS가 기사를 실제로 어떻게 처리했는지 알려줍니다. 같은 내용으로 여러 번 보내도 결과는 같습니다.
        보고된 `cms_url`은 월간 품질 리포트와 AI 데스크 점검(공개 페이지 확인)에 쓰입니다.
        풀·이벤트 방식에서는 보고를 권장하고, 푸시 방식에서는 `articleUpsert` 응답으로 대신합니다.
      security:
        - apiKey: [articles:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Delivery" }
      responses:
        "200":
          description: 기록됨
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Delivery" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: 보고한 `version`보다 새 버전이 이미 있음. 최신 버전을 다시 받아 처리하세요.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /webhooks:
    get:
      tags: [웹훅]
      operationId: listWebhooks
      summary: 웹훅 목록
      security:
        - apiKey: [webhooks:manage]
      responses:
        "200":
          description: 등록된 웹훅
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [웹훅]
      operationId: createWebhook
      summary: 웹훅 등록
      description: |
        응답의 `secret`은 이때 한 번만 보여줍니다. 이 값으로 `X-Robot-Signature`를 검증하세요.
        `mode: push`로 등록하면 이벤트 대신 `articleUpsert` 계약으로 기사를 직접 보냅니다.
      security:
        - apiKey: [webhooks:manage]
      parameters:
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
      responses:
        "201":
          description: 등록됨
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: "#/components/schemas/Webhook" }
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: 서명 검증용 비밀값 (다시 조회할 수 없음)
                        example: whsec_9f2c1e7a4b...
        "400": { $ref: "#/components/responses/BadRequest" }

  /webhooks/{webhook_id}:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string, example: wh_01JBF3Q8 }
    delete:
      tags: [웹훅]
      operationId: deleteWebhook
      summary: 웹훅 삭제
      security:
        - apiKey: [webhooks:manage]
      responses:
        "204": { description: 삭제됨 }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/{webhook_id}/test:
    parameters:
      - name: webhook_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [웹훅]
      operationId: testWebhook
      summary: 시험 이벤트 보내기
      description: 샌드박스 기사로 `article.published` 이벤트(또는 push 모드면 `articleUpsert`)를 한 번 보내고 결과를 돌려줍니다.
      security:
        - apiKey: [webhooks:manage]
      responses:
        "200":
          description: 전송 결과
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  status_code: { type: [integer, "null"], example: 200 }
                  duration_ms: { type: integer, example: 182 }
                  error: { type: [string, "null"] }
        "404": { $ref: "#/components/responses/NotFound" }

  /feeds/{module}.json:
    parameters:
      - { $ref: "#/components/parameters/FeedModule" }
      - { $ref: "#/components/parameters/FeedToken" }
    get:
      tags: [피드]
      operationId: getJsonFeed
      summary: JSON Feed
      description: |
        [JSON Feed 1.1](https://www.jsonfeed.org/version/1.1/) 형식으로 최근 기사 50건을 돌려줍니다.
        `_robot` 확장 필드에 `article_id`, `version`, `status`, `dedupe_key`가 들어 있습니다.
        격리된 기사는 피드에서 빠지므로, 격리를 반영해야 하는 CMS는 API나 웹훅을 쓰세요.
      security: []
      responses:
        "200":
          description: JSON Feed
          content:
            application/feed+json:
              schema: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /feeds/{module}.rss:
    parameters:
      - { $ref: "#/components/parameters/FeedModule" }
      - { $ref: "#/components/parameters/FeedToken" }
    get:
      tags: [피드]
      operationId: getRssFeed
      summary: RSS 2.0
      description: RSS 2.0 형식으로 최근 기사 50건을 돌려줍니다. `<guid>`는 `article_id`이고 본문은 `<content:encoded>`에 들어 있습니다.
      security: []
      responses:
        "200":
          description: RSS
          content:
            application/rss+xml:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /usage:
    get:
      tags: [사용량]
      operationId: getUsage
      summary: 오늘 사용량과 한도
      security:
        - apiKey: [articles:read]
      responses:
        "200":
          description: 사용량
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

webhooks:
  robotEvent:
    post:
      summary: 기사 이벤트 (로봇 기자 → CMS)
      operationId: robotEvent
      description: |
        `mode: events`로 등록한 URL에 보냅니다. CMS는 10초 안에 2xx로 응답해야 하며, 그렇지 않으면
        1분·5분·30분·2시간·6시간·24시간 간격으로 재시도합니다. 같은 이벤트가 두 번 올 수 있으니 `id`로 중복을 거르세요.
        순서가 뒤바뀌어 도착할 수 있으므로, 이미 가진 기사보다 `data.article.version`이 낮으면 무시하세요.
      parameters:
        - { $ref: "#/components/parameters/SignatureHeader" }
        - name: X-Robot-Event
          in: header
          required: true
          schema: { $ref: "#/components/schemas/EventType" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Event" }
      responses:
        "2XX":
          description: 수신 확인. 응답 본문은 무시합니다.
  articleUpsert:
    put:
      summary: 기사 넣기·고치기 (로봇 기자 → CMS, push 모드)
      description: |
        `mode: push`로 등록한 CMS가 구현하는 계약입니다. 로봇 기자는 `{등록한 url}/{article_id}`로 `PUT`합니다.
        - 처음 받은 `article_id`면 새 글을 만들고, 이미 있으면 덮어씁니다. 같은 요청이 다시 와도 글이 하나만 있어야 합니다.
        - `status`가 `withdrawn`이면 글을 비공개로 돌립니다. 삭제하지 마세요.
        - `status`가 `held`이면 초안(편집자 승인 대기)으로 둡니다.
        - 이미지는 `media[].url`에서 받아 CMS에 다시 올려도 되고, 그대로 링크해도 됩니다.
        재시도 규칙은 `robotEvent`와 같습니다.
      operationId: articleUpsert
      parameters:
        - name: X-Robot-Article-Id
          in: header
          required: true
          description: URL 끝의 `{article_id}`와 같은 값 (프록시가 경로를 바꾸는 환경용)
          schema: { type: string }
        - { $ref: "#/components/parameters/SignatureHeader" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Article" }
      responses:
        "200":
          description: 처리 결과. 로봇 기자는 이 값을 발행 결과 보고로 기록합니다.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Delivery" }
        "409":
          description: CMS에 더 새로운 버전이 이미 있음. 로봇 기자는 재시도하지 않습니다.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: rr_live_… / rr_test_…
      description: |
        `Authorization: Bearer rr_live_…` 헤더로 보냅니다. 키마다 권한 범위를 정할 수 있습니다:
        `articles:read`(조회), `articles:write`(발행 결과 보고), `webhooks:manage`(웹훅 관리).
        `rr_test_` 키는 샌드박스에서만 동작합니다.

  parameters:
    ArticleId:
      name: article_id
      in: path
      required: true
      schema: { type: string, pattern: "^art_[0-9A-Z]{26}$", example: art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1 }
    Cursor:
      name: cursor
      in: query
      description: 이전 응답의 `next_cursor`
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: 같은 키로 24시간 안에 다시 보내면 처음 결과를 그대로 돌려줍니다.
      schema: { type: string, maxLength: 64 }
    SignatureHeader:
      name: X-Robot-Signature
      in: header
      required: true
      description: |
        `t={unix초},v1={hex}` 형식. `v1 = HMAC-SHA256(secret, "{t}.{요청 본문 원문}")`.
        `t`가 현재 시각과 5분 넘게 차이 나면 거부하세요. 비밀값 교체 기간에는 `v1`이 두 개 올 수 있습니다.
      schema: { type: string, example: "t=1792905600,v1=5f8e2c…" }
    FeedModule:
      name: module
      in: path
      required: true
      description: 모듈 키 또는 `all`
      schema:
        oneOf:
          - { $ref: "#/components/schemas/ModuleKey" }
          - { type: string, const: all }
    FeedToken:
      name: token
      in: query
      required: true
      description: 피드 전용 읽기 토큰 (API 키와 별개로 발급, URL에 넣어도 되는 범위만 가짐)
      schema: { type: string, example: rrf_3d9a… }

  headers:
    RateLimitRemaining:
      description: 이번 1분 창에서 남은 요청 수
      schema: { type: integer }

  responses:
    BadRequest:
      description: 요청 형식 오류
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: API 키가 없거나 잘못됐거나, 권한 범위가 부족함
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: 없는 기사이거나 구독하지 않은 모듈의 기사
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    TooManyRequests:
      description: 요청 한도 초과. `Retry-After` 초만큼 기다린 뒤 다시 시도하세요.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    ModuleKey:
      type: string
      enum: [weather, quake, culture, trend, policy, auction, stock, disclosure, market]
      description: |
        | 키 | 모듈 | 중복 기준 (`dedupe_key`) |
        |---|---|---|
        | weather | 날씨·기상특보 | 특보 발표 시각 + 특보 구역 |
        | quake | 지진 알림 | 기상청 지진 통보 번호 |
        | culture | 문화 생활 | 행사 ID + 시작일 |
        | trend | 인터넷 핫이슈 | 키워드 + 시간대(1시간) |
        | policy | 정책 브리핑 | 보도자료 ID |
        | auction | 법원 경매 정보 | 사건번호 + 매각기일 |
        | stock | 증권 종목 현황 | 종목코드 + 거래일 + 회차 |
        | disclosure | 기업 공시 | 전자공시 접수번호 |
        | market | AI 시황 (장전·마감) | 거래일 + 장전/마감 |

    Module:
      type: object
      required: [key, name, enabled, tier]
      properties:
        key: { $ref: "#/components/schemas/ModuleKey" }
        name: { type: string, example: 날씨·기상특보 }
        tier:
          type: string
          enum: [lite, standard, pro]
          description: 이 모듈이 처음 포함되는 요금제
        enabled:
          type: boolean
          description: 현재 요금제(또는 단품 추가)로 쓸 수 있는지
        schedule:
          type: object
          description: 발행 일정. 이 시간 밖의 무발행은 장애가 아닙니다.
          properties:
            days:
              type: array
              items: { type: string, enum: [mon, tue, wed, thu, fri, sat, sun] }
              example: [mon, tue, wed, thu, fri, sat, sun]
            trading_days_only:
              type: boolean
              description: 증권 모듈처럼 장 운영일에만 발행하면 true
            fixed_times:
              type: array
              items: { type: string, pattern: "^\\d{2}:\\d{2}$" }
              example: ["08:01", "15:01"]
        last_published_at:
          type: [string, "null"]
          format: date-time
        health:
          type: string
          enum: [ok, stalled, off_schedule]
          description: "`stalled`: 일정 안인데 기대 주기보다 오래 발행이 없음"

    Article:
      type: object
      required: [id, version, module, dedupe_key, status, title, body_html, created_at, updated_at]
      properties:
        id:
          type: string
          description: 기사 고유 ID. CMS 쪽 중복 방지 키로 쓰세요.
          example: art_01JBF2X7K9M4Q8R2T6V0W3Y5Z1
        version:
          type: integer
          minimum: 1
          description: 수정될 때마다 1씩 오릅니다.
          example: 2
        module: { $ref: "#/components/schemas/ModuleKey" }
        dedupe_key:
          type: string
          description: 같은 사건을 가리키는 키. 로봇 기자는 같은 키로 기사를 두 번 만들지 않습니다.
          example: weather:202610230400:L1020100
        status: { $ref: "#/components/schemas/ArticleStatus" }
        title: { type: string, example: "\"남해동부안쪽먼바다, 풍랑주의보 발표\"… 상황 변화 주시" }
        subtitle: { type: [string, "null"] }
        summary:
          type: [string, "null"]
          description: CODA 요약 엔진이 본문에서 뽑은 2~3문장 (프로 이상). 본문에 없는 내용은 들어가지 않습니다.
        body_html:
          type: string
          description: |
            본문 HTML. 허용 태그: p, h2, h3, strong, em, a, ul, ol, li, table, thead, tbody, tr, th, td, figure, img, figcaption, blockquote, small.
            스크립트·iframe·인라인 이벤트 속성은 들어가지 않습니다. 인라인 `style`은 표와 강조 상자에만 씁니다.
        body_text:
          type: string
          description: 태그를 뺀 본문 (검색 색인·SNS 미리보기용)
        byline: { type: string, example: 수완뉴스 로봇 기자 }
        category_hint:
          type: string
          description: CMS 카테고리 연결용 힌트. 실제 카테고리는 CMS 설정에서 모듈별로 매핑하세요.
          example: 날씨 예보
        tags:
          type: array
          items: { type: string }
          example: [풍랑주의보, 남해동부]
        region:
          type: [object, "null"]
          properties:
            code: { type: string, example: L1020100 }
            name: { type: string, example: 남해동부안쪽먼바다 }
            sido_code: { type: [string, "null"], example: "48" }
        entities:
          type: array
          description: 기사에 나온 종목·기관 등 (증권·공시 모듈)
          items:
            type: object
            properties:
              type: { type: string, enum: [stock, company, agency, person, place] }
              id: { type: string, example: "005930" }
              name: { type: string, example: 삼성전자 }
        media:
          type: array
          items: { $ref: "#/components/schemas/Media" }
        sources:
          type: array
          description: 근거 데이터 출처. 기사 하단 출처 표기에 쓰세요.
          items:
            type: object
            required: [name]
            properties:
              name: { type: string, example: 기상청 기상특보 }
              url: { type: [string, "null"], format: uri }
              retrieved_at: { type: string, format: date-time }
        event_at:
          type: [string, "null"]
          format: date-time
          description: 사건 기준 시각 (특보 발표·지진 발생·공시 접수·장 마감 등)
          example: "2026-10-23T04:00:00+09:00"
        data_as_of:
          type: [string, "null"]
          format: date-time
          description: 본문 수치의 기준 시각. 기사 상단에 "○시 기준"으로 표기하세요.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        published_at:
          type: [string, "null"]
          format: date-time
          description: 로봇 기자가 공개 가능으로 판정한 시각
        review: { $ref: "#/components/schemas/Review" }
        delivery:
          oneOf:
            - { $ref: "#/components/schemas/Delivery" }
            - { type: "null" }
          description: 이 API 키의 CMS가 마지막으로 보고한 발행 결과

    ArticleStatus:
      type: string
      enum: [published, held, withdrawn]
      description: |
        - `published` — 공개해도 되는 기사
        - `held` — 검수 대기. 초안으로 두고 편집자 승인 후 공개하세요 (검수 대기 모드, 일일 한도 초과분)
        - `withdrawn` — 격리. 이미 공개했다면 비공개로 돌리세요. 삭제하지는 마세요.

    Review:
      type: object
      description: AI 데스크 점검 결과
      required: [state]
      properties:
        state:
          type: string
          enum: [unchecked, passed, corrected, flagged]
          description: |
            - `unchecked` — 아직 점검 전 (발행 후 최대 3시간)
            - `passed` — 이상 없음
            - `corrected` — 경미한 오류를 고침 (`version` 증가, 수정 내용은 `/versions`)
            - `flagged` — 사실관계 확인 필요. 보통 `status: withdrawn`과 함께 옵니다.
        checked_at: { type: [string, "null"], format: date-time }
        desk:
          type: [string, "null"]
          enum: [market, weather, trend, null]
        reasons:
          type: array
          items: { type: string }
          example: ["같은 날 중복 발행"]

    ArticleVersion:
      type: object
      required: [version, created_at, change]
      properties:
        version: { type: integer }
        created_at: { type: string, format: date-time }
        change:
          type: string
          enum: [created, corrected, held, withdrawn, restored]
        changed_fields:
          type: array
          items: { type: string }
          example: [title, body_html]
        note:
          type: [string, "null"]
          description: 정정 사유 (정정 보도 문구로 써도 되는 수준으로 작성됨)
          example: 제목의 특보 종류를 '강풍주의보'에서 '풍랑주의보'로 바로잡음

    Media:
      type: object
      required: [id, role, url]
      properties:
        id: { type: string, example: med_01JBF2XA }
        role:
          type: string
          enum: [featured, inline]
          description: "`featured`는 대표 이미지(기사당 최대 1개). `inline`은 본문 `<img>`에도 같은 URL로 들어 있습니다."
        url:
          type: string
          format: uri
          description: 최소 90일 유지됩니다. 장기 보관이 필요하면 CMS에 다시 올리세요.
        mime_type: { type: string, example: image/png }
        width: { type: integer, example: 1200 }
        height: { type: integer, example: 900 }
        alt: { type: string, example: 남해동부 특보 구역 지도 }
        caption: { type: [string, "null"] }
        credit: { type: string, example: 기상청 }

    Delivery:
      type: object
      required: [version, state]
      properties:
        version:
          type: integer
          description: CMS가 처리한 기사 버전
        state:
          type: string
          enum: [published, draft, unpublished, rejected]
          description: CMS에서의 실제 상태
        cms_id: { type: [string, "null"], example: "48213" }
        cms_url: { type: [string, "null"], format: uri, example: "https://news.example.com/articles/48213" }
        reason:
          type: [string, "null"]
          description: "`rejected`일 때 사유"
        delivered_at: { type: string, format: date-time, readOnly: true }

    EventType:
      type: string
      enum: [article.published, article.updated, article.held, article.withdrawn, module.stalled, quota.reached]
      description: |
        - `article.published` — 새 기사가 공개 가능해짐
        - `article.updated` — 공개된 기사가 교정됨 (`version` 증가)
        - `article.held` — 검수 대기로 들어감
        - `article.withdrawn` — 격리됨. 비공개로 돌리세요.
        - `module.stalled` — 일정 안인데 모듈 발행이 멈춤 (`data.module`)
        - `quota.reached` — 오늘 발행 한도 도달 (`data.usage`). 이후 기사는 `held`로 옵니다.

    Event:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, example: evt_01JBF31H }
        type: { $ref: "#/components/schemas/EventType" }
        created_at: { type: string, format: date-time }
        data:
          type: object
          properties:
            article: { $ref: "#/components/schemas/Article" }
            module: { $ref: "#/components/schemas/Module" }
            usage: { $ref: "#/components/schemas/Usage" }

    Webhook:
      type: object
      required: [id, url, mode, events, active]
      properties:
        id: { type: string, example: wh_01JBF3Q8 }
        url: { type: string, format: uri, example: "https://cms.example.com/hooks/robot" }
        mode:
          type: string
          enum: [events, push]
          description: "`events`는 `robotEvent`, `push`는 `articleUpsert` 계약으로 보냅니다."
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
        modules:
          type: array
          description: 비우면 구독 중인 모든 모듈
          items: { $ref: "#/components/schemas/ModuleKey" }
        active: { type: boolean }
        last_delivery:
          type: [object, "null"]
          properties:
            at: { type: string, format: date-time }
            status_code: { type: [integer, "null"] }
            failures_in_row: { type: integer }

    WebhookCreate:
      type: object
      required: [url, mode]
      properties:
        url: { type: string, format: uri, description: "HTTPS만 허용" }
        mode: { type: string, enum: [events, push] }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
          description: 생략하면 전체 이벤트
        modules:
          type: array
          items: { $ref: "#/components/schemas/ModuleKey" }

    Usage:
      type: object
      required: [plan, date, articles_today, daily_limit]
      properties:
        plan: { type: string, enum: [lite, standard, pro, enterprise] }
        date: { type: string, format: date, example: "2026-10-25" }
        articles_today: { type: integer, example: 312 }
        daily_limit:
          type: [integer, "null"]
          description: "`null`이면 제한 없음 (엔터프라이즈)"
          example: 500
        held_over_limit:
          type: integer
          description: 오늘 한도를 넘겨 `held`로 보낸 기사 수
        sites_used: { type: integer, example: 2 }
        sites_limit: { type: [integer, "null"], example: 3 }

    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri, example: "https://api.swnest.net/robot/problems/invalid-parameter" }
        title: { type: string, example: 잘못된 요청 값 }
        status: { type: integer, example: 400 }
        detail: { type: string, example: "limit은 1~100 사이여야 합니다." }
        code:
          type: string
          enum: [invalid_parameter, unauthorized, forbidden_scope, not_found, module_not_in_plan, version_conflict, rate_limited, internal_error]
        request_id: { type: string, example: req_7c1d0e }