BizCrush 발행 기사 API 연동 안내

콘캣 뉴스 엔진 → 비즈크러시 프론트엔드 · 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입니다.

호출 예시

JavaScript (브라우저)

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 (동작 확인용)

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)입니다. 증분 동기화를 한다면 마지막으로 본 커서를 저장해 두고 이어서 요청하면 됩니다.

홈·상세 화면용 확장 (2026-08-18 추가)

홈·상세 UI 렌더에 필요한 표면 3개가 추가됐습니다. 인증·CORS는 기존과 동일합니다.

목록 경량 응답 — ?include_body=false

홈처럼 카드만 그리는 화면에서는 본문 전문이 필요 없습니다. include_body=false를 붙이면 body_mdrefsnull로 옵니다 — 나머지 필드(제목·요약·매체명·날짜·조회수·태그 등)는 전부 그대로입니다.

기사 단건 — 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 **또는 내려간 기사**입니다 — 상세 화면은 "기사를 찾을 수 없습니다"로 처리하세요.
}

인기 랭킹 — 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(요청 윈도 안의 조회수 합 — 이 값의 내림차순이 랭킹).

응답 필드

{
  "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입니다(경량 응답 — 위 확장 절)

시각 필드 — 서버는 UTC, 표시는 프론트

모든 시각 필드(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)를 그대로 되던지는 정상 흐름에서는 자동으로 충족되므로 신경 쓸 일이 없습니다.

이미지 (2026-08-18 추가 · 개통 안내 후 연결)

기사 본문 이미지가 images 배열로 내려갑니다: [{ src, caption, ai_generated }].

개통 시점

이미지는 배포 준비 중입니다(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오류가 아니라 「같은 조회자가 오늘 이미 본 기사」라는 뜻입니다. 새로고침 연타는 서버가 흡수하므로 프론트에서 디바운스를 걸 필요가 없습니다.

🔴 두 가지만 지켜 주세요

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도 내려가지 않으니, 개통 안내를 받은 뒤에 연결해 주세요.

🔴 렌더 시 지켜야 할 것 (계약상 의무)

  1. publisher · source_url · ai_notice 3필드는 화면에서 빠뜨리면 안 됩니다. 출처 표기와 AI 고지는 원문 매체와의 계약 이행 의무입니다.
  2. ai_notice 문면은 그대로 노출합니다 — 자체 문구로 대체하지 않습니다.
  3. source_notice가 null이면 안내문을 렌더하지 않습니다 (자체 작성 기사이거나 원문 링크가 없는 경우). null이 아니면 source_url도 반드시 있습니다.
  4. body_md 안에 출처: 로 시작하는 출처 블록과 ## 참고자료 절이 본문에 포함되어 있습니다. 이를 화면의 출처 영역으로 승격해 렌더해도 되지만, 그 경우 publisher· source_url·source_notice같은 정보의 한 벌이므로 두 번 노출하지 않습니다. 분리가 어려우면 본문에 그대로 두는 것이 안전합니다 (표기가 사라지는 쪽이 계약 위반).
  5. 날짜 표기: 「매체명 · 날짜」에는 source_published_at(원문 발행일)을 씁니다. published_at은 우리 쪽 발행 시각이라 다른 축입니다.
  6. ai_generated: true인 이미지는 캡션(AI 생성 표기)을 반드시 함께 렌더합니다 — 위 「이미지」 절 참조. 이것도 1~4와 같은 계약 이행 의무입니다.

운영 특성