수완뉴스 로봇 기자 API
워드프레스가 아닌 CMS에서 로봇 기자 기사를 받아 발행하기 위한 REST API. 날씨·특보부터 증권 공시까지, 로봇 기자 모듈 9종이 만든 기사를 어떤 CMS에서든 받아 발행할 수 있습니다.
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: 비공개로 전환, 삭제하지 않음
빠른 시작
풀 방식
# 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 저장이벤트 방식 — 웹훅 등록
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
{
"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
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);import crypto from "node:crypto";
// Express: app.post("/hooks/robot", express.raw({ type: "application/json" }), handler)
export function verifyRobotSignature(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = header.split(",").filter((kv) => kv.startsWith("v1=")).map((kv) => kv.slice(3));
return given.some((sig) => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}import hashlib, hmac, time
def verify_robot_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [kv.split("=", 1) for kv in header.split(",") if "=" in kv]
t = next((int(v) for k, v in parts if k == "t"), None)
if t is None or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")공통 규칙
- 시각은 모두 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 | 기업 공시 | 전자공시 접수번호 |
| market | AI 시황 (장전·마감) | 거래일 + 장전/마감 |
워드프레스 연동과의 대응
현재 lab.swnest.net 워드프레스에 쓰는 방식을 이 API의 개념으로 옮기면 다음과 같습니다.
| 워드프레스 (현재) | 로봇 기자 API | 비고 |
|---|---|---|
| 기사 ID | id (art_…) | WP 글 ID와 별개. CMS 쪽 중복 방지 키 |
슬러그 rnn-wrn-202610230400-…, kma-… | dedupe_key | 봇이 같은 사건을 두 번 쓰지 않게 하는 키 |
publish | status: published | |
draft (검수 대기 모드, 한도 초과) | status: held | |
검수필요 태그 + 초안 전환 | status: withdrawn, review.state: flagged | AI 데스크 격리 |
AI검수완료 태그 | review.state: corrected | 수정본은 version 증가 |
| WP 리비전 | GET /articles/{id}/versions | 정정 사유 note 포함 |
| 본문 첫 이미지(썸네일 폴백) | media[role=featured] | 대표 이미지를 명시적으로 지정 |
| 카테고리 ID (9, 16 …) | module + category_hint | CMS에서 모듈별로 카테고리 매핑 |
모듈
구독 중인 로봇 기자 모듈과 발행 일정
/modules모듈 목록
API 키 · articles:read
전체 모듈과 현재 요금제에서 쓸 수 있는지(enabled)를 돌려줍니다.
응답
- 200 모듈 목록 application/json object
- 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
기사
로봇 기자가 만든 기사 조회와 CMS 발행 결과 보고
/articles기사 목록
API 키 · articles:read
updated_after를 마지막으로 받은 기사의 updated_at으로 두고 반복 호출하면 새 기사와 수정본을 빠짐없이 받을 수 있습니다. 정렬은 항상 updated_at 오름차순입니다. 1분보다 짧은 간격으로 호출하지 마세요.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
module | query | string | 모듈 키. 쉼표로 여러 개 지정할 수 있습니다. |
status | query | string | 기사 상태. 쉼표로 여러 개 지정할 수 있습니다. 생략하면 전체입니다. |
updated_after | query | string date-time | 이 시각 이후 생성·수정된 기사만 (해당 시각 미포함) |
event_after | query | string date-time | 사건 기준 시각( |
region | query | string | 지역 코드(행정표준코드 앞 2자리 시도 코드 또는 기상청 특보 구역 코드) |
cursor | query | string | 이전 응답의 |
limit | query | integer |
응답
/articles/{article_id}기사 한 건
API 키 · articles:read
항상 최신 버전을 돌려줍니다. 응답의 ETag를 If-None-Match로 보내면 바뀌지 않았을 때 304를 받습니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
article_id필수 | path | string |
응답
/articles/{article_id}/versions기사 수정 이력
API 키 · articles:read
AI 데스크 교정·격리 이력을 버전별로 돌려줍니다. 정정 보도 표기를 할 때 씁니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
article_id필수 | path | string |
응답
- 200 버전 목록 (오래된 순) application/json object
- 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
/articles/{article_id}/deliveryCMS 발행 결과 보고
API 키 · articles:write
CMS가 기사를 실제로 어떻게 처리했는지 알려줍니다. 같은 내용으로 여러 번 보내도 결과는 같습니다. 보고된 cms_url은 월간 품질 리포트와 AI 데스크 점검(공개 페이지 확인)에 쓰입니다. 풀·이벤트 방식에서는 보고를 권장하고, 푸시 방식에서는 articleUpsert 응답으로 대신합니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
article_id필수 | path | string |
요청 본문
응답
웹훅
기사 이벤트를 받을 URL 관리
/webhooks웹훅 목록
API 키 · webhooks:manage
응답
- 200 등록된 웹훅 application/json object
- 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
/webhooks웹훅 등록
API 키 · webhooks:manage
응답의 secret은 이때 한 번만 보여줍니다. 이 값으로 X-Robot-Signature를 검증하세요. mode: push로 등록하면 이벤트 대신 articleUpsert 계약으로 기사를 직접 보냅니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
Idempotency-Key | header | string | 같은 키로 24시간 안에 다시 보내면 처음 결과를 그대로 돌려줍니다. |
요청 본문
응답
/webhooks/{webhook_id}웹훅 삭제
API 키 · webhooks:manage
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
webhook_id필수 | path | string |
응답
- 204 삭제됨
- 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
/webhooks/{webhook_id}/test시험 이벤트 보내기
API 키 · webhooks:manage
샌드박스 기사로 article.published 이벤트(또는 push 모드면 articleUpsert)를 한 번 보내고 결과를 돌려줍니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
webhook_id필수 | path | string |
응답
- 200 전송 결과 application/json object
- 404 없는 기사이거나 구독하지 않은 모듈의 기사 application/problem+json Problem
피드
API를 직접 호출하기 어려운 CMS를 위한 읽기 전용 피드
/feeds/{module}.jsonJSON Feed
인증 없음 (피드 토큰)
JSON Feed 1.1 형식으로 최근 기사 50건을 돌려줍니다. _robot 확장 필드에 article_id, version, status, dedupe_key가 들어 있습니다. 격리된 기사는 피드에서 빠지므로, 격리를 반영해야 하는 CMS는 API나 웹훅을 쓰세요.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
module필수 | path | ModuleKey | all | 모듈 키 또는 |
token필수 | query | string | 피드 전용 읽기 토큰 (API 키와 별개로 발급, URL에 넣어도 되는 범위만 가짐) |
응답
- 200 JSON Feed application/feed+json object
- 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
/feeds/{module}.rssRSS 2.0
인증 없음 (피드 토큰)
RSS 2.0 형식으로 최근 기사 50건을 돌려줍니다. <guid>는 article_id이고 본문은 <content:encoded>에 들어 있습니다.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
module필수 | path | ModuleKey | all | 모듈 키 또는 |
token필수 | query | string | 피드 전용 읽기 토큰 (API 키와 별개로 발급, URL에 넣어도 되는 범위만 가짐) |
응답
- 200 RSS application/rss+xml string
- 401 API 키가 없거나 잘못됐거나, 권한 범위가 부족함 application/problem+json Problem
사용량
요금제 한도와 사용량
/usage오늘 사용량과 한도
API 키 · articles:read
응답
웹훅 (로봇 기자 → CMS)
로봇 기자가 CMS로 보내는 요청입니다. 두 번째(articleUpsert)는 push 모드를 쓰는 CMS가 직접 구현해야 하는 계약입니다.
{등록한 url}기사 이벤트 (로봇 기자 → CMS)
로봇 기자 → CMS · events 모드
mode: events로 등록한 URL에 보냅니다. CMS는 10초 안에 2xx로 응답해야 하며, 그렇지 않으면 1분·5분·30분·2시간·6시간·24시간 간격으로 재시도합니다. 같은 이벤트가 두 번 올 수 있으니 id로 중복을 거르세요. 순서가 뒤바뀌어 도착할 수 있으므로, 이미 가진 기사보다 data.article.version이 낮으면 무시하세요.
파라미터
| 이름 | 위치 | 타입 | 설명 |
|---|---|---|---|
X-Robot-Signature필수 | header | string |
|
X-Robot-Event필수 | header | EventType |
요청 본문
응답
- 2XX 수신 확인. 응답 본문은 무시합니다.
{등록한 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필수 | header | string | URL 끝의 |
X-Robot-Signature필수 | header | string |
|
요청 본문
응답
- 200 처리 결과. 로봇 기자는 이 값을 발행 결과 보고로 기록합니다. application/json Delivery
- 409 CMS에 더 새로운 버전이 이미 있음. 로봇 기자는 재시도하지 않습니다.
스키마
ModuleKey
| 키 | 모듈 | 중복 기준 (dedupe_key) |
|---|---|---|
| weather | 날씨·기상특보 | 특보 발표 시각 + 특보 구역 |
| quake | 지진 알림 | 기상청 지진 통보 번호 |
| culture | 문화 생활 | 행사 ID + 시작일 |
| trend | 인터넷 핫이슈 | 키워드 + 시간대(1시간) |
| policy | 정책 브리핑 | 보도자료 ID |
| auction | 법원 경매 정보 | 사건번호 + 매각기일 |
| stock | 증권 종목 현황 | 종목코드 + 거래일 + 회차 |
| disclosure | 기업 공시 | 전자공시 접수번호 |
| market | AI 시황 (장전·마감) | 거래일 + 장전/마감 |
Module
| 필드 | 타입 | 설명 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
key필수 | ModuleKey | |||||||||||||
name필수 | string | 예: | ||||||||||||
tier필수 | string lite · standard · pro | 이 모듈이 처음 포함되는 요금제 | ||||||||||||
enabled필수 | boolean | 현재 요금제(또는 단품 추가)로 쓸 수 있는지 | ||||||||||||
schedule | object | 발행 일정. 이 시간 밖의 무발행은 장애가 아닙니다.
| ||||||||||||
last_published_at | string | null date-time | |||||||||||||
health | string ok · stalled · off_schedule |
|
Article
| 필드 | 타입 | 설명 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
id필수 | string | 기사 고유 ID. CMS 쪽 중복 방지 키로 쓰세요. 예: | ||||||||||||
version필수 | integer | 수정될 때마다 1씩 오릅니다. 예: | ||||||||||||
module필수 | ModuleKey | |||||||||||||
dedupe_key필수 | string | 같은 사건을 가리키는 키. 로봇 기자는 같은 키로 기사를 두 번 만들지 않습니다. 예: | ||||||||||||
status필수 | ArticleStatus | |||||||||||||
title필수 | string | 예: | ||||||||||||
subtitle | string | null | |||||||||||||
summary | string | 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·인라인 이벤트 속성은 들어가지 않습니다. 인라인 | ||||||||||||
body_text | string | 태그를 뺀 본문 (검색 색인·SNS 미리보기용) | ||||||||||||
byline | string | 예: | ||||||||||||
category_hint | string | CMS 카테고리 연결용 힌트. 실제 카테고리는 CMS 설정에서 모듈별로 매핑하세요. 예: | ||||||||||||
tags | string[] | |||||||||||||
region | object | null | |||||||||||||
entities | object[] | 기사에 나온 종목·기관 등 (증권·공시 모듈)
| ||||||||||||
media | Media[] | |||||||||||||
sources | object[] | 근거 데이터 출처. 기사 하단 출처 표기에 쓰세요.
| ||||||||||||
event_at | string | null date-time | 사건 기준 시각 (특보 발표·지진 발생·공시 접수·장 마감 등) 예: | ||||||||||||
data_as_of | string | null date-time | 본문 수치의 기준 시각. 기사 상단에 "○시 기준"으로 표기하세요. | ||||||||||||
created_at필수 | string date-time | |||||||||||||
updated_at필수 | string date-time | |||||||||||||
published_at | string | null date-time | 로봇 기자가 공개 가능으로 판정한 시각 | ||||||||||||
review | Review | |||||||||||||
delivery | Delivery | null | 이 API 키의 CMS가 마지막으로 보고한 발행 결과 |
ArticleStatus
published— 공개해도 되는 기사held— 검수 대기. 초안으로 두고 편집자 승인 후 공개하세요 (검수 대기 모드, 일일 한도 초과분)withdrawn— 격리. 이미 공개했다면 비공개로 돌리세요. 삭제하지는 마세요.
Review
AI 데스크 점검 결과
| 필드 | 타입 | 설명 |
|---|---|---|
state필수 | string unchecked · passed · corrected · flagged |
|
checked_at | string | null date-time | |
desk | string | null market · weather · trend | |
reasons | string[] |
ArticleVersion
| 필드 | 타입 | 설명 |
|---|---|---|
version필수 | integer | |
created_at필수 | string date-time | |
change필수 | string created · corrected · held · withdrawn · restored | |
changed_fields | string[] | |
note | string | null | 정정 사유 (정정 보도 문구로 써도 되는 수준으로 작성됨) 예: |
Media
| 필드 | 타입 | 설명 |
|---|---|---|
id필수 | string | 예: |
role필수 | string featured · inline |
|
url필수 | string uri | 최소 90일 유지됩니다. 장기 보관이 필요하면 CMS에 다시 올리세요. |
mime_type | string | 예: |
width | integer | 예: |
height | integer | 예: |
alt | string | 예: |
caption | string | null | |
credit | string | 예: |
Delivery
| 필드 | 타입 | 설명 |
|---|---|---|
version필수 | integer | CMS가 처리한 기사 버전 |
state필수 | string published · draft · unpublished · rejected | CMS에서의 실제 상태 |
cms_id | string | null | 예: |
cms_url | string | null uri | 예: |
reason | string | null |
|
delivered_at | string date-time |
EventType
article.published— 새 기사가 공개 가능해짐article.updated— 공개된 기사가 교정됨 (version증가)article.held— 검수 대기로 들어감article.withdrawn— 격리됨. 비공개로 돌리세요.module.stalled— 일정 안인데 모듈 발행이 멈춤 (data.module)quota.reached— 오늘 발행 한도 도달 (data.usage). 이후 기사는held로 옵니다.
Event
Webhook
WebhookCreate
Usage
| 필드 | 타입 | 설명 |
|---|---|---|
plan필수 | string lite · standard · pro · enterprise | |
date필수 | string date | 예: |
articles_today필수 | integer | 예: |
daily_limit필수 | integer | null |
예: |
held_over_limit | integer | 오늘 한도를 넘겨 |
sites_used | integer | 예: |
sites_limit | integer | null | 예: |
Problem
| 필드 | 타입 | 설명 |
|---|---|---|
type필수 | string uri | 예: |
title필수 | string | 예: |
status필수 | integer | 예: |
detail | string | 예: |
code | string invalid_parameter · unauthorized · forbidden_scope · not_found · module_not_in_plan · version_conflict · rate_limited · internal_error | |
request_id | string | 예: |
원본 명세
아래 YAML을 Swagger UI, Redoc, Postman, openapi-generator 등에 그대로 넣어 클라이언트·서버 코드를 만들 수 있습니다.
openapi.yaml 펼치기
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 }