콘캣 뉴스 엔진 → 비즈크러시 프론트엔드 · 2026-08-14 개통 · 2026-08-18 조회수·시각 처리 + 홈·상세 확장(단건 조회 · 인기 랭킹 · 경량 목록 · 태그 · 이미지) 추가
재구성·검수를 거쳐 발행된 기사를 제공하는 API입니다. 브라우저(프론트엔드)에서 직접 호출하도록 설계됐습니다.
| 항목 | 값 |
|---|---|
| 기사 목록 | GET https://engine-api-czjhjbmhwq-du.a.run.app/v1/published (경량 옵션 include_body=false) |
| 기사 단건 | GET https://engine-api-czjhjbmhwq-du.a.run.app/v1/published/{slug} (2026-08-18 추가) |
| 인기 랭킹 | GET https://engine-api-czjhjbmhwq-du.a.run.app/v1/published/popular?days=&limit= (2026-08-18 추가) |
| 조회수 기록 | POST https://engine-api-czjhjbmhwq-du.a.run.app/v1/published/{slug}/view (2026-08-18 추가) |
| 인증 | 요청 헤더 x-api-key: <발급받은 키> — 전 경로 같은 키입니다 |
| 키 전달 | 별도 보안 채널로 전달합니다 (이 문서에 없음) |
| 허용 메서드 | GET · 조회수 기록 경로만 POST |
| CORS 허용 origin | https://bizcru.sh · http://localhost:3000 (그 외 도메인·서브도메인에서는 브라우저가 응답을 차단합니다 — 필요 시 요청 주세요) |
키 없이 호출하면 401, 잘못된 키도 401입니다.
const BASE = "https://engine-api-czjhjbmhwq-du.a.run.app";
async function fetchPublished({ updatedSince, afterId, limit = 100, includeBody = true } = {}) {
const params = new URLSearchParams();
if (updatedSince) params.set("updated_since", updatedSince); // ISO8601
if (afterId) params.set("after_id", String(afterId));
params.set("limit", String(limit)); // 기본 100 · 최대 500
if (!includeBody) params.set("include_body", "false"); // 홈 목록용 경량 응답 (아래 참조)
const res = await fetch(`${BASE}/v1/published?${params}`, {
headers: { "x-api-key": API_KEY },
});
if (!res.ok) throw new Error(`published API ${res.status}`);
return res.json(); // { items: [...], next: {...} | null }
}
curl -s -H "x-api-key: $API_KEY" \
"https://engine-api-czjhjbmhwq-du.a.run.app/v1/published?limit=10"
응답의 next가 커서입니다. null이면 마지막 페이지입니다.
⚠️ 필드명이 비대칭입니다 — next.published_at 값을 다음 요청의 updated_since 파라미터로
되던집니다 (after_id는 이름 그대로):
let cursor = {};
while (true) {
const { items, next } = await fetchPublished(cursor);
render(items);
if (!next) break;
cursor = { updatedSince: next.published_at, afterId: next.after_id };
}
커서 축은 우리 쪽 발행 시각(published_at)입니다. 증분 동기화를 한다면 마지막으로 본
커서를 저장해 두고 이어서 요청하면 됩니다.
홈·상세 UI 렌더에 필요한 표면 3개가 추가됐습니다. 인증·CORS는 기존과 동일합니다.
?include_body=false홈처럼 카드만 그리는 화면에서는 본문 전문이 필요 없습니다. include_body=false를 붙이면
body_md와 refs가 null로 옵니다 — 나머지 필드(제목·요약·매체명·날짜·조회수·태그 등)는
전부 그대로입니다.
GET /v1/published/{slug}상세 화면에 URL로 직접 진입할 때 씁니다. 응답은 목록의 아이템 1건과 완전히 같은 형태 (전문 포함)입니다.
const res = await fetch(`${BASE}/v1/published/${encodeURIComponent(slug)}`, {
headers: { "x-api-key": API_KEY },
});
if (res.status === 404) {
// 없는 slug **또는 내려간 기사**입니다 — 상세 화면은 "기사를 찾을 수 없습니다"로 처리하세요.
}
404 — 존재하지 않거나 발행이 내려간 slug입니다. 두 경우를 구분해 주지 않습니다(의도).GET /v1/published/popular?days=N&limit=M「오늘의 인기 뉴스」용입니다. 최근 N일(한국시간 달력일, 오늘 포함) 조회수 합 내림차순 상위 M건을 냅니다.
const res = await fetch(`${BASE}/v1/published/popular?days=2&limit=10`, {
headers: { "x-api-key": API_KEY },
});
const { items, days } = await res.json();
| 파라미터 | 범위 | 기본 |
|---|---|---|
days |
1~30 (밖이면 422) | 2 |
limit |
1~50 (밖이면 422) | 10 |
응답 아이템은 경량 필드셋입니다(본문 없음 — 클릭 시 단건 조회로):
id · slug · title · publisher · source_published_at · published_at · section ·
view_count(누적) · window_view_count(요청 윈도 안의 조회수 합 — 이 값의 내림차순이 랭킹).
view_count(누적)와 window_view_count(기간)는 다른 축입니다. 화면의 「조회 N」에
누적을 쓸지 기간을 쓸지는 프론트 선택이지만, 순위는 기간 합 기준입니다.{
"items": [
{
"id": 141,
"slug": "20260813-141",
"lang": "ko",
"market": "KR",
"title": "AI 데이터센터는 한국에, 수익은 해외로…AIDC 투자의 역설",
"subtitle": "…",
"section": { "name": "AI", "code": "S1N8" },
"body_md": "…(마크다운 본문)…",
"refs": ["https://…"],
"published_at": "2026-08-13T…",
"source_published_at": "2026-08-13T…",
"updated_at": "2026-08-13T…",
"writer_mode": "reconstruct",
"publisher": "아이티데일리",
"source_url": "https://…",
"ai_notice": "…(고지 문면)…",
"source_notice": "…(출처 안내문)…",
"view_count": 128,
"tags": ["실시간 통역", "시장 동향"],
"images": [
{ "src": "https://storage.googleapis.com/…", "caption": "…", "ai_generated": false }
]
}
],
"next": { "published_at": "2026-08-13T…", "after_id": 141 }
}
| 필드 | 설명 |
|---|---|
title / body_md |
기사 제목 · 마크다운 본문 |
publisher |
원문 매체명. ⚠️ 원문이 2건 이상인 기사는 null (귀속 표기가 본문 안에 있음) |
source_url |
원문 URL |
ai_notice |
AI 생성 고지 문면 |
source_notice |
출처 안내문. null이면 렌더하지 않음 |
published_at |
우리 발행 시각 (커서 축) |
source_published_at |
원문 발행일 — 화면의 「매체명 · 날짜」 자리는 이쪽. null 가능 |
subtitle |
원문 부제 (null 가능) |
section |
원문 카테고리 {name, code, sub_name, sub_code} — 어느 키를 노출할지는 프론트 판단 |
refs |
참고 자료 URL 목록 |
writer_mode |
reconstruct(협약 재구성) 또는 original(자체 작성) |
slug |
발행 시 확정, 재발행에도 불변 — URL 경로로 사용 가능 |
view_count |
누적 조회수. 0이면 조회 없음(null이 오지 않습니다). 아래 「조회수」 절 참조 |
tags |
표시용 태그 목록 — 상세 화면 태그 칩 자리. null = 없음(그때는 칩을 렌더하지 않음) |
images |
본문 이미지 목록 — 아래 「이미지」 절 참조. null = 아직 준비 전(발행 직후 최대 30분) / [] = 실을 이미지 없음 |
body_md / refs |
⚠️ include_body=false 호출에서는 null입니다(경량 응답 — 위 확장 절) |
모든 시각 필드(published_at · source_published_at · updated_at)는 오프셋이 명시된
ISO 8601이고, 표기는 UTC(Z 또는 +00:00)로 고정되어 있습니다. 절대 시각이므로 어느
타임존의 브라우저에서 파싱해도 같은 순간입니다.
사용자 타임존·언어로 바꿔 보여주는 것은 프론트 몫입니다. new Date(value)로 파싱한 뒤
Intl.DateTimeFormat으로 포맷하면 됩니다:
new Intl.DateTimeFormat("ko-KR", {
dateStyle: "long",
timeZone: "Asia/Seoul",
}).format(new Date(item.source_published_at));
// → "2026년 8월 13일"
다국어 지원 시에는 locale 인자만 바뀝니다 ("ja-JP" → 2026年8月13日). 서버는 locale별
문자열을 만들어 주지 않습니다 — 서버가 표시 문자열까지 만들면 지원 언어를 늘릴 때마다 API가
바뀌어야 하므로, 시각은 항상 기계가 읽는 형태로만 내려갑니다.
⚠️ 날짜를 문자열 자르기로 뽑지 마세요. published_at.slice(0, 10)은 UTC 기준 날짜라,
한국시간 자정~오전 9시 사이에 발행된 기사는 화면에서 하루 전 날짜로 보입니다. 반드시 Date로
파싱한 뒤 timeZone을 지정해 포맷하세요.
보내는 쪽도 같은 규칙입니다 — updated_since 파라미터는 오프셋 포함 값이어야 하고, 오프셋
없는 값(예: 2026-08-18T00:00:00)은 422로 거부됩니다. 커서(next.published_at)를 그대로
되던지는 정상 흐름에서는 자동으로 충족되므로 신경 쓸 일이 없습니다.
기사 본문 이미지가 images 배열로 내려갑니다: [{ src, caption, ai_generated }].
src는 콘캣의 공개 버킷 URL입니다 — 원문 매체 서버가 아니라 저희가 재호스팅한
사본이라, 원문 쪽 사정으로 이미지가 깨질 걱정 없이 그대로 <img src>에 쓰면 됩니다.images[0] 을 쓰면 됩니다(첫 번째가 대표 후보).images: null은 「아직 준비 전」입니다 — 발행 직후 최대 30분간 이미지 사본을 만드는
중일 수 있습니다. null이면 이미지 없이 렌더하고, 다음 목록 폴링에서 채워진 값을 받습니다.
[]는 「이 기사에 실을 이미지가 없음」이라 계속 없다는 뜻입니다(둘을 구분하세요).ai_generated: true인 이미지는 caption을 반드시 함께 노출해야 합니다.
그 캡션이 「AI로 생성한 이미지」라는 출처 표기이고, 원문 매체와의 계약 이행 의무입니다 —
표기 없이 이미지만 노출하면 안 됩니다. false인 이미지의 캡션 노출 여부는 자유입니다.이미지는 배포 준비 중입니다(2026-08-18 기준). 개통 전에는 images가 항상 null이니,
null 처리를 넣어 두시면 개통 시 자동으로 나타납니다. 개통되면 별도로 안내드립니다.
기사별 조회수는 기록(POST)과 노출(목록의 view_count)이 따로입니다.
프론트가 조회를 기록하고, 그 누적값을 기존 목록 응답에서 읽습니다.
POST /v1/published/{slug}/view기사 상세 화면에 진입할 때 한 번 호출합니다. 목록 렌더에서는 호출하지 않습니다.
function recordView(slug) {
fetch(`${BASE}/v1/published/${encodeURIComponent(slug)}/view`, {
method: "POST",
keepalive: true, // 페이지를 떠나는 중에도 전송이 끊기지 않습니다
headers: { "x-api-key": API_KEY },
}).catch(() => {}); // 실패해도 화면에 영향 없음 — 결과를 볼 필요가 없습니다
}
응답은 항상 200 {"counted": true|false}입니다.
counted: false는 오류가 아니라 「같은 조회자가 오늘 이미 본 기사」라는 뜻입니다.
새로고침 연타는 서버가 흡수하므로 프론트에서 디바운스를 걸 필요가 없습니다.
404 — 발행 상태가 아닌 slug입니다 (내려간 기사 포함).401 — 키 문제입니다.1. navigator.sendBeacon은 쓸 수 없습니다.
커스텀 헤더(x-api-key)를 실을 수 없기 때문입니다. 위 예시처럼 fetch + keepalive: true를
씁니다.
2. 서버(BFF)에서 호출한다면 viewer_key가 필수입니다.
| 호출 위치 | body | 조회자 판별 |
|---|---|---|
| 브라우저에서 직접 | 생략 | 접속 IP + User-Agent 해시 (서버가 처리) |
| Next.js 서버·route handler·서버 액션 경유 | {"viewer_key": "…"} 필수 |
보낸 viewer_key의 해시 |
서버 뒤에서 호출하면서 viewer_key를 빠뜨리면 전 사용자가 조회자 한 명으로 접힙니다 —
접속 IP가 전부 그 서버 것이라 하루 1건만 집계되고 조회수가 사실상 멈춥니다.
오류가 나지 않아 눈치채기 어렵습니다.
// 서버(BFF) 경유일 때만
await fetch(url, {
method: "POST",
headers: { "x-api-key": API_KEY, "content-type": "application/json" },
body: JSON.stringify({ viewer_key: viewerKey }), // 8~128자
});
viewer_key는 쿠키에 심어 둔 익명 식별자나 그 해시면 충분합니다. 콘캣은 sha256 해시만
저장하고 원값은 남기지 않습니다. 브라우저에서 직접 호출하는 쪽을 권합니다 — CORS가
이미 열려 있고 viewer_key를 관리하지 않아도 됩니다.
같은 조회자 × 같은 기사 × 같은 날(한국시간) 은 1회로 셉니다. 자정을 넘기면 같은 사람이 같은 기사를 봐도 다시 집계됩니다.
view_count새 조회 엔드포인트가 아니라 기존 /v1/published 응답 아이템에 필드가 하나 붙습니다.
말미 추가라 지금 쓰고 계신 파싱은 그대로 동작합니다.
⚠️ 기사 단건 조회 엔드포인트는 없습니다. 최신 조회수는 목록을 다시 받아야 반영됩니다.
POST 응답에는 갱신된 카운트가 오지 않습니다 (counted 불리언 하나뿐).
⚠️ 참고 지표입니다. 호출 횟수 제한이 없는 공개 표면의 카운트라, 정산·계약 근거 같은 증빙 용도로는 쓸 수 없습니다.
조회수는 2026-08-18 배포 예정입니다. 배포 전에는 POST가 404를 돌려주고 view_count도
내려가지 않으니, 개통 안내를 받은 뒤에 연결해 주세요.
publisher · source_url · ai_notice 3필드는 화면에서 빠뜨리면 안 됩니다.
출처 표기와 AI 고지는 원문 매체와의 계약 이행 의무입니다.ai_notice 문면은 그대로 노출합니다 — 자체 문구로 대체하지 않습니다.source_notice가 null이면 안내문을 렌더하지 않습니다 (자체 작성 기사이거나 원문 링크가
없는 경우). null이 아니면 source_url도 반드시 있습니다.body_md 안에 출처: 로 시작하는 출처 블록과 ## 참고자료 절이 본문에 포함되어
있습니다. 이를 화면의 출처 영역으로 승격해 렌더해도 되지만, 그 경우 publisher·
source_url·source_notice와 같은 정보의 한 벌이므로 두 번 노출하지 않습니다.
분리가 어려우면 본문에 그대로 두는 것이 안전합니다 (표기가 사라지는 쪽이 계약 위반).source_published_at(원문 발행일)을 씁니다.
published_at은 우리 쪽 발행 시각이라 다른 축입니다.ai_generated: true인 이미지는 캡션(AI 생성 표기)을 반드시 함께 렌더합니다 —
위 「이미지」 절 참조. 이것도 1~4와 같은 계약 이행 의무입니다.