microCMSブログ

microCMSの最新情報や活用事例などをお届けします。

寄稿

マネジメントAPIで、公開終了した記事を SmartNewsから自動で取り下げる

マネジメントAPIで、公開終了した記事を SmartNewsから自動で取り下げる
松下 祐子 松下 祐子

株式会社メンバーズの松下です。
普段はフロントエンドエンジニアとして、microCMSとAstroを使ったJamstack構成の開発を行っています。

今回は、マネジメントAPI(ベータ版)を使って、公開終了した記事をSmartNewsから自動で取り下げる方法をご紹介します。
「CMS上で記事を公開終了にしたのに、SmartNews側には掲載され続けている」という状況を自動で解消することができます。

ベータ版として提供されているマネジメントAPIは、これまで管理画面でしか行えなかった参照や操作をAPI経由で扱えるようにする機能です。コンテンツAPIとの最大の違いは、作成者やステータスといった管理情報を取得できる点にあります。今回はそのうちの公開終了日時( closedAt )を使います。

はじめに

本記事のスコープ

実装例はSmartNewsのSmartFormatを対象としています。仕様が公開されており、手順を具体的に示せるためです。
Gunosyなど他のニュースアグリゲーターも独自のフィード仕様を持ち、取り下げの考え方自体は近いのですが、仕様書がパートナー契約後に提供される形式のため本記事では扱いません。媒体によっては管理画面から削除申請を行う運用の場合もあり、考え方は応用できてもコードをそのまま流用できるものではない点はご了承ください。

また、本記事はmicroCMSとAstroを使ったブログがすでに構築されていることを前提としています。基本的な構成についてはこちらを参考にしてください。

フィードから記事を消してもニュースアグリゲーターから取り下げられない

ニュースアグリゲーターへ記事を配信するときは、多くの場合フィードを使います。媒体側が更新情報をXMLで公開し、アグリゲーターが定期的に取得(クロール)してアプリに掲載する仕組みです。
記事を取り下げたいときは「フィード(XML)からその記事を消せばよい」と考えてしまいがちですが、フィードからの削除だけでは取り下げられません。
SmartNews 媒体運営者サポートサイトでも、フィードから記事要素( item や entry )を削除しただけではSmartNewsに削除フラグが送られず、記事は削除できないと明記されています。

参考:配信した記事を停止・削除する

記事を取り下げるには、記事要素を消さずに残したまま、削除フラグを立てる必要があります。

<item>
  <title>取り下げたい記事</title>
  <link>https://example.com/articles/xxxx</link>
  <pubDate>Thu, 06 Aug 2026 10:36:08 GMT</pubDate>
  <content:encoded><![CDATA[<p>本文...</p>]]></content:encoded>
  <media:status>deleted</media:status>
</item>

標準のRSS 2.0やAtomの仕様には「この記事を削除してほしい」と伝えるタグは存在しません。各アグリゲーターが独自の名前空間で拡張しており、SmartNewsの場合はこの media:status を deleted にした item を、一定期間フィードに残し続ける方法が取り下げの正しい伝え方です。

参考:SmartFormat仕様書(RSS2.0準拠)

マネジメントAPIが解決

削除フラグ付きの item を作るには、SmartFormatの必須要素 title / link / pubDate​​ / content:encoded を埋める必要があります。このうち link は記事のidから組み立てられ、 pubDate は publishedAt から作りますが、 title と content:encoded は記事の中身そのものです。つまり、公開終了した記事の中身と、その記事が公開終了だという事実の両方が必要になります。
コンテンツAPIだけでは実現できませんでしたが、マネジメントAPIの提供により実現可能になりました。

2つのAPIは次のような役割分担になります。

  • コンテンツAPI:その記事の中身(タイトル・本文・画像)を取得
  • マネジメントAPI: status と closedAt から、どの記事がいつ公開終了したかを判別

ステータスと公開終了日時はコンテンツAPIのレスポンスに含まれないため、どの記事を取り下げるべきかはマネジメントAPIでしか分かりません。一方でマネジメントAPIが返すのはメタ情報だけなので、記事の中身はコンテンツAPIから取る必要があります。

2つのAPIのレスポンスを突き合わせて、削除フラグ付きの item を組み立てるのがポイントです。

全体の流れは次のようになります。
​​microCMSで記事を公開終了にするとWebhookが飛び、ホスティング先でサイトが再ビルドされます。
このビルドの中で2つのAPIから情報を取得し、突き合わせて smartformat.xml を生成します。

2つのAPIは順番に呼びます。どの記事が公開終了したかはマネジメントAPIでしか分からないため、先に対象のidを特定し、そのidを指定してコンテンツAPIから中身を取得します。

図は記事の公開終了を起点にしていますが、記事を公開したときも流れは同じです。ビルドのたびにすべての記事を見てフィードを作り直すので、違いはその記事に付く  media:status が active になるか deleted になるかだけです。

手順

1. 準備

1-1. ソフトウェアバージョン

本記事では下記のバージョンで開発を行っています。
バージョンの差異によって挙動が異なる可能性があります。

  • Node.js 24.19.0
  • Astro 5.17.1
  • TypeScript(Astro同梱)

1-2. 前提となるブログ環境

  • microCMS × Astro 構成のブログサイト

2. microCMS側の設定

必要な設定は2つです。マネジメントAPIの権限を付けることと、公開終了記事を取得するためのAPIキーを分けることです。APIキーを分ける理由は後述します。

2-1. マネジメントAPIの権限を付ける

マネジメントAPIは、APIキーに権限を付与するだけで使えます。管理画面の「APIキー」から対象のキーを開き、「マネジメントAPI(ベータ)」タブで「コンテンツの取得(一覧・詳細)」を有効にします。

参考:APIキーの権限管理

2-2. 公開終了記事の取得用にAPIキーを分ける

公開終了した記事は、通常コンテンツAPIのレスポンスから消えます。フィード生成に必要な本文を取得するにはコンテンツAPIの「公開終了コンテンツの全取得」の権限が必要です。

本番で使用しているAPIキーにこの権限を追加してしまうと、本番サイトにも公開終了コンテンツが表示されてしまいます。

そこでAPIキーをサイト本体用キーとフィード生成用キーの2つに分けます。サイト本体用のキーにはこの権限を付けないので、公開終了コンテンツがサイトに表示されることなくフィード生成も可能になります。

サイト本体用キー

フィード生成用キー

.env の変数

MICROCMS_API_KEY

MICROCMS_FEED_API_KEY

コンテンツAPI: GET

✓

✓(対象エンドポイントのみ)

コンテンツAPI: 公開終了コンテンツの全取得

×

✓

コンテンツAPI: 下書きコンテンツの全取得

×

×

コンテンツAPI: POST / PUT / PATCH / DELETE

×

×

マネジメントAPI: コンテンツの取得(一覧・詳細)

✓

×

フィード生成用APIキーは、デフォルト権限をすべてOFFにし、対象エンドポイントの個別権限だけで「GET」と「公開終了コンテンツの全取得」を有効にするのが最小権限です(後者は「GET」との同時付与が必須)。下書きの取得や書き込み系、マネジメントAPIの権限はいずれも不要です。

APIキー数はプランによって上限があり、Hobbyプランは1個のため、APIキーを分ける構成はTeamプラン以上を前提としています。プランは今後変動する可能性があるため、詳細は料金プランのAPIキー数をご確認ください。
Hobbyプランで試す場合は、サイト本体と同じキーに一時的に権限を付与し、検証後にOFFへ戻す方法があります。付与中はサイト側の一覧に公開終了記事が現れる点にご注意ください。

参考:公開終了コンテンツを取得する

2-3. マネジメントAPIの注意点

マネジメントAPIを使う上での注意事項は以下です。

  • ベースURLがコンテンツAPIと異なる(https://{SERVICE_ID}.microcms-management.io)
  • レートリミットはIPアドレス単位で10回 / 10秒
  • レスポンスはCDNを経由しないため、リクエスト数がそのままカウントされる

コンテンツAPIの「サービス単位で60回/1秒、CDNキャッシュから返ればカウントされない」仕様と比べると、かなり厳しい条件です。リクエストのたびに叩く使い方には向いていませんが、今回のようにAstroのビルド時に一度だけ取得する構成は、レートリミットの面でもAPIキーをクライアントに露出させないセキュリティの面でも相性が良いと言えます。

※APIキーは漏洩すると悪用されるおそれがあるため、環境変数名の先頭に PUBLIC_ を付けないようご注意ください。付けるとクライアント側のバンドルに埋め込まれ、APIキーが公開されます。

参考:制限事項/注意事項

3. Astro側の実装

3-1. ファイル構成

フィードの生成に関わるファイルは次の5つです。

src/
├── pages/smartformat.xml.ts      フィードのエンドポイント(prerender = true)
├── library/management.ts         マネジメントAPIクライアント(100件ずつの分割取得・レート制御・リトライ)
├── library/posts.ts              コンテンツAPIからの取得(公開中の一覧/公開終了記事の単体取得)
├── library/smartformat.ts        削除フラグの判定ロジックとXML生成
└── library/smartformat-feed.ts   上記を束ねる生成パイプライン

エンドポイントは次のとおりです。 prerender = true を付けているので、ビルド時に一度だけ実行されて静的なXMLが出力されます。APIキーもビルド時のサーバーサイドでしか使われず、クライアントには渡りません。

src/pages/smartformat.xml.ts
import { SMARTFORMAT_CONFIG } from '@library/smartformat';
import { formatFeedSummary, generateSmartFormatFeed } from '@library/smartformat-feed';
import type { APIContext } from 'astro';

export const prerender = true;

export async function GET(context: APIContext): Promise<Response> {
  const baseUrl = context.site?.toString() ?? SMARTFORMAT_CONFIG.siteUrl;

  const result = await generateSmartFormatFeed({ baseUrl });

  // ビルドログに残しておくと、フィードに何が載って何が落ちたかを後から追える
  console.log('[smartformat] ' + formatFeedSummary(result).split('\n').join('\n[smartformat] '));

  return new Response(result.xml, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
    },
  });
}

 @library/* は src/library/* へのパスエイリアス( tsconfig.json の paths)です。設定していない場合は相対パスに読み替えてください。

 baseUrl は astro.config.mjs の site から受け取っています。itemの link はCanonical URLを設定する必要があるため、記事のidと組み合わせてフルパスのURLを作ります。フィードの中身を作るのは generateSmartFormatFeed() なので、次で詳しく中身を見ていきます。

3-2. SmartFormatの必須要素

今回必要なフィードの中身の要素をまとめました。

channel:フィード全体の情報

要素

区分

内容・条件

title

必須

フィードの名前

link

必須

サイトのトップページのURL

description

必須

フィードの説明

pubDate

必須

XMLを生成した日時

item

必須

記事1件分の要素(中身の詳細は次の表に記載)

language

任意

言語(ja)

copyright

任意

著作権表記

ttl

任意

クロール間隔。1〜5分が推奨

snf:logo

任意

媒体のロゴ画像

item:記事1件の情報

要素

区分

内容・条件

title

必須

記事のタイトル 

link

必須

記事のCanonical URL
クエリパラメータは付けられず、日本語URLも不可(エンコードが必要)

pubDate

必須

記事の公開日時(RFC822形式)

content:encoded

必須

記事の全文
使えるHTMLタグはホワイトリスト制で、iframe / script / video / audio は含まれない

media:status

任意(取り下げには必須)

active / deleted
deletedで削除フラグとして機能する

media:thumbnail

任意

サムネイル画像

description

任意

記事の要約

dc:creator

任意

著者名

ホワイトリストに無いタグは、自動で除去せず検出して警告を出すだけにしました。リッチエディタで動画埋め込みを使っていると引っかかりますが、勝手に本文を書き換えるほうが危険なためです。
名前空間は4つ宣言します。

<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:dc="http://purl.org/dc/elements/1.1/"
     xmlns:media="http://search.yahoo.com/mrss/"
     xmlns:snf="http://www.smartnews.be/snf">

microCMS側に用意するフィールドは、タイトル・本文(リッチエディタ)・サムネイル(画像)で足ります。公開終了の判定はマネジメントAPIの status / closedAt を使うため、コンテンツ側にフィールドは不要です。

3-3. マネジメントAPIからメタ情報を取得する

マネジメントAPIから全件のメタ情報を取得します。1回のリクエストで取得できる上限は100件のため、offsetをずらしながら複数回に分けて取得します。
レートリミットは10回/10秒のため、平均すると1リクエスト/秒です。これに引っかからないように、リクエストを1本のプロミスチェーンに直列化し、リクエスト1つあたりの間隔を1200ms空ける実装にしました。

src/library/management.ts
const MIN_REQUEST_INTERVAL_MS = 1200;

let requestChain: Promise<unknown> = Promise.resolve();
let lastRequestAt = 0;

function enqueue<T>(task: () => Promise<T>): Promise<T> {
  const scheduled = requestChain.then(async () => {
    const waitMs = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
    if (waitMs > 0) {
      await sleep(waitMs);
    }
    lastRequestAt = Date.now();
    return task();
  });
  // 呼び出し側でrejectしても列自体は止めない
  requestChain = scheduled.catch(() => undefined);
  return scheduled;
}

 requestChain に次のリクエストを繋ぐことで、同時に呼び出されても実行は1本の列に並び、必ず前のリクエストが終わってから次が実行されます。待ち時間の管理はこの列の中だけで行っているので、取得する側のコードは間隔を気にする必要はありません。分割取得処理の中に sleep を書いていないのはこのためで、呼び出す場所が増えても間隔は自動的に守られます。
それでも、ステータスコードで429(リクエストが多すぎる)や5xx(サーバー側の問題)が返ることはあります。この場合は Retry-After ヘッダーがあればその秒数だけ待ち、なければ3秒 → 6秒 → 12秒と待ち時間を倍にしながら最大3回リトライします。最初の待ちを3秒と長めにとっているのは、レートリミットが「10秒あたり10回」という区切りで数えられているためです。数秒待ってから送り直せば、回数がリセットされた次の区切りに入りやすくなります。

マネジメントAPIのGET /api/v1/contents/{endpoint}ドキュメントには status に「 DRAFT , PUBLISH, CLOSED, PUBLISH_AND_DRAFT のいずれかが入ります」と記載されており、実際のレスポンスは "status": ["CLOSED"] という配列形式でした。 status.includes('CLOSED') で判定できます。「公開中かつ下書きあり」は  PUBLISH_AND_DRAFT という独立した値なので、公開中を判定したいときは両方を見る必要があります。

3-4. 削除フラグを判定する

ここが実装の中心です。「いつまで削除フラグを残すか」を決めます。今回は以下のルールを採用しました。

  1.  publishedAt (公開日)が2週間より古い記事は、公開終了していても最初から除外する
  2. 公開日が保持期間内なら、 closedAt から14日以内は deleted を付けてフィードに残し、14日を過ぎたら完全に除外する

1つ目について補足します。SmartFormatの仕様には「SmartNews側の記事保持期間は2週間」「公開日が2週間より前の記事はクロールされない場合がある」と記載されています。削除フラグが効くのは、公開日から2週間以内の記事に限られます。
「1か月前に公開した記事を今日公開終了した」というケースでは、SmartNews側はすでにその記事を保持していないため、 deleted を出しても意味がありません。なお、フィードに載せるitemの件数について、現行のヘルプセンターでは上限が明記されておらず、フィード全体で1MB以内が推奨されています。今回はitem数の上限を100件とし、超過分は公開日の新しい順に残して、落とした件数とidを警告に出しています。

判定は次のような形で行われます。

src/library/smartformat.ts
export function isDeletedItemCandidate(meta: ManagementContentMeta, now: Date): boolean {
  if (!meta.publishedAt || !isClosed(meta)) return false;
  if (diffInDays(new Date(meta.publishedAt), now) > FEED_ARTICLE_RETENTION_DAYS) return false;
  const closedAt = new Date(meta.closedAt ?? meta.updatedAt);
  return diffInDays(closedAt, now) <= DELETED_STATUS_GRACE_DAYS;
}

meta.closedAt ?? meta.updatedAt としているのは、ドキュメント上は起こらないはずですが、 status が CLOSED なのに closedAt が null だと判定できないためです。 updatedAt を代替に使い、判定できずにフラグを立て損なうことを避けています。
なお  closedAt は必ず publishedAt 以降なので、2つの期間をどちらも14日にしている限り、2つ目の判定には到達しません。先に公開日の条件で除外されるためです。二段構えに見えて、いまの設定では一段目で決まっており、保持期間を延ばしたときに初めて2つ目が効いてきます。

判定ロジックは、14日の境界(14.0日は含む/14.1日は除外)や pubDate の書式など50パターンで確認しています。「2つの期間が同じ値である間は2つ目の判定に到達しない」こともテストに含めているため、期間を変更したときに気付けます。

3-5. 公開終了記事の中身を取得する

どのidが公開終了かはマネジメントAPIで分かっているため、コンテンツAPIにはid指定の単体取得で問い合わせます。

src/library/smartformat-feed.ts
// どれが公開終了かはマネジメントAPIでしか分からないので、まずidを特定してから取得する
const closedIds = metaList.filter((meta) => isDeletedItemCandidate(meta, now)).map((meta) => meta.id);
const closed = await getClosedPostsByIds(BLOG_ENDPOINT, closedIds);
posts.push(...closed.items);

対象は「公開日が保持期間内かつ公開終了から猶予期間内」に絞られるのでリクエスト数は数件で収まります。取得するフィールドも fields で必要な4つだけです。
権限が足りない場合、公開終了コンテンツは404で返ります。ここはエラーとせず、警告を出して処理を続けます。取得できなかった記事は title に id を入れたitemとして出力されます。中身が復元できなくても、削除を伝えること自体は優先するという判断です。

3-6. XMLを生成する

 media:status には、公開中の記事なら active、公開終了した記事なら deleted を付与します。 media:status は仕様上は任意要素で、付け忘れてもチェックツールでは検出されません。未指定時のデフォルトは active ですが、意図を明示するため常に出力しています。

公開中の記事は、マネジメントAPIのメタ情報とコンテンツAPIの本文を突き合わせてitemを組み立てます。 postById はコンテンツAPIから取得した記事をidで引けるようにしたMapです。

src/library/smartformat.ts
// ここまで来たら公開中か下書きのみ
if (!isPublished(meta)) {
  decisions.push({ ...base, reason: 'excluded-draft-only', included: false });
  continue;
}

const post = postById.get(meta.id);
if (!post) {
  // 公開中のはずなのにコンテンツAPIに無い。権限や取得漏れを疑う
  warnings.push(`[${meta.id}] 公開中のはずですがコンテンツAPIから取得できませんでした。`);
  decisions.push({ ...base, reason: 'excluded-no-content-source', included: false });
  continue;
}

const disallowedTags = findDisallowedTags(post.content);
if (disallowedTags.length > 0) {
  warnings.push(
    `[${meta.id}] content:encoded に SmartFormat で許可されていないタグが含まれます: ${disallowedTags.join(', ')}`,
  );
}

const thumbnailCheck = validateThumbnail(post.eyecatch?.width, post.eyecatch?.height);
if (post.eyecatch && !thumbnailCheck.ok) {
  warnings.push(`[${meta.id}] media:thumbnail を出力しません: ${thumbnailCheck.reason}`);
}

items.push({
  id: meta.id,
  title: post.title,
  link: toCanonicalUrl(meta.id, baseUrl),
  guid: toCanonicalUrl(meta.id, baseUrl),
  pubDate: publishedAt,
  status: 'active',
  contentHtml: post.content,
  description: toPlainTextExcerpt(post.content),
  thumbnailUrl: thumbnailCheck.ok ? post.eyecatch?.url : undefined,
  creator: SMARTFORMAT_CONFIG.defaultCreator,
});

decisions.push({ ...base, reason: 'included-active', included: true, status: 'active' });

ホワイトリスト外のタグは検出して警告するだけで本文は書き換えません。サムネイルは最小サイズ(200×200px以上、400px以上推奨)とアスペクト比(長辺 ≦ 短辺 × 3.3)の条件を満たさなければ media:thumbnail を出力しません。任意要素なので、不正な値を出すより省略するほうがよいと判断しました。著者名は設定値の固定値です。

decisions には、載せた記事と落とした記事の理由を記録します。ビルドログに出しておくと、フィードの中身が想定と違ったときに追えます。

item 1件の組み立てはこうなりました。任意要素は値があるときだけ足していきます。

src/library/smartformat.ts
function renderItem(item: FeedItem): string {
  const lines = [
    '    <item>',
    `      <title>${escapeXml(item.title)}</title>`,
    `      <link>${escapeXml(item.link)}</link>`,
    // 仕様書の要素一覧表には無いが、公式サンプルには含まれているのでlinkと同じ値を出す
    `      <guid>${escapeXml(item.guid)}</guid>`,
    `      <pubDate>${toRfc822(item.pubDate)}</pubDate>`,
  ];

  if (item.description) {
    lines.push(`      <description>${cdata(item.description)}</description>`);
  }

  lines.push(`      <content:encoded>${cdata(item.contentHtml)}</content:encoded>`);
  lines.push(`      <dc:creator>${escapeXml(item.creator)}</dc:creator>`);

  if (item.thumbnailUrl) {
    lines.push(`      <media:thumbnail url="${escapeXml(item.thumbnailUrl)}" />`);
  }

  // 未指定時のデフォルトは active だが、意図を明示するため常に出力する
  lines.push(`      <media:status>${item.status}</media:status>`);

  // SmartFormat 2.0で追加された任意要素。設定されていなければ出さない
  if (SMARTFORMAT_CONFIG.analyticsHtml) {
    lines.push(`      <snf:analytics>${cdata(SMARTFORMAT_CONFIG.analyticsHtml)}</snf:analytics>`);
  }

  lines.push('    </item>');

  return lines.join('\n');
}

あとは channel の骨格にitemを並べれば、フィード1本になります。

src/library/smartformat.ts
export interface BuildFeedOptions {
  items: FeedItem[];
  /** channel/pubDate に入れるXMLの生成日時。記事の日付ではない */
  generatedAt: Date;
  baseUrl?: string;
}

export function buildSmartFormatXml({ items, generatedAt, baseUrl }: BuildFeedOptions): string {
  const site = baseUrl ?? SMARTFORMAT_CONFIG.siteUrl;
  const channelLink = new URL('/', site).toString();
  const logoUrl = new URL(SMARTFORMAT_CONFIG.logoPath, site).toString();

  return `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/"
  xmlns:media="http://search.yahoo.com/mrss/"
  xmlns:snf="http://www.smartnews.be/snf">
  <channel>
    <title>${escapeXml(SMARTFORMAT_CONFIG.title)}</title>
    <link>${escapeXml(channelLink)}</link>
    <description>${escapeXml(SMARTFORMAT_CONFIG.description)}</description>
    <pubDate>${toRfc822(generatedAt)}</pubDate>
    <language>${escapeXml(SMARTFORMAT_CONFIG.language)}</language>
    <copyright>${escapeXml(SMARTFORMAT_CONFIG.copyright)}</copyright>
    <ttl>${SMARTFORMAT_CONFIG.ttlMinutes}</ttl>
    <snf:logo>
      <url>${escapeXml(logoUrl)}</url>
    </snf:logo>
    <image>
      <url>${escapeXml(logoUrl)}</url>
      <title>${escapeXml(SMARTFORMAT_CONFIG.title)}</title>
      <link>${escapeXml(channelLink)}</link>
    </image>
${items.map(renderItem).join('\n')}
  </channel>
</rss>
`;
}

channel の pubDate はXMLを生成した日時です。記事の日付ではないので、判定基準の時刻とは別に扱っています。

pubDate の書式にも注意が必要です。RFC822形式(EEE, dd MMM yyyy HH:mm:ss z)で、タイムゾーンはGMT(+0000)または運営者のタイムゾーン(日本なら +0900)で指定します。

日本時間の2026年5月16日 15:00 を表す場合、正しい書き方は次の2つです。

  • Sat, 16 May 2026 06:00:00 GMT   (GMTに変換した値 + GMT表記)
  • Sat, 16 May 2026 15:00:00 +0900   (日本時間のままの値 + +0900表記)

誤りやすいのは、日本時間の値のままGMTを付けてしまうケースです。

  • Sat, 16 May 2026 15:00:00 GMT

この場合、9時間ずれた時刻(日本時間の5/17 00:00)として解釈されます。

microCMSの日時はISO 8601形式(2026-08-06T10:36:08.000Z)なので変換が必要ですが、曜日名や月名を組み立てる処理はロケールやタイムゾーンの影響を受けます。 Date#toUTCString() がGMT固定で正しい形式を返してくれるので、素直にこれを使うのが安全です。

エスケープ周りとあわせて、ヘルパーは3つだけです。

src/library/smartformat.ts
function escapeXml(value: string): string {
  return value
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

/** CDATAセクション。本文に ]]> が含まれていてもセクションが閉じないように分割する。 */
function cdata(value: string): string {
  return `<![CDATA[${value.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
}

export function toRfc822(date: Date): string {
  return date.toUTCString();
}

 cdata() で ]]> を置き換えているのは、本文にこの文字列が含まれるとCDATAセクションが途中で閉じてXMLが壊れるためです。リッチエディタの本文をそのまま流し込む以上、起こりうると考えて入れました。

3-7. 取り下げが反映されるまでのタイミング

公開終了から実際の取り下げまでは「再ビルド」と「クロール」の2段階を経るため、即時反映ではありません。急ぎで取り下げる必要がある場合は、配信先の管理画面から手動で対応する選択肢も併せてご検討ください。

4. 実装後の検証

本記事の検証は、SmartNewsと未契約の状態で実施できる範囲にとどまります。確認できるのは「フィードが仕様に沿って正しく生成され、削除フラグが正しく解釈されるか」までです。

4-1. 確認の手順

SmartNewsは、フィードが仕様に適合しているかを確認できるSmartFormatチェックツールを公開しています。URLを指定するかXMLファイルをアップロードすると判定され、契約がなくても利用できます。

手順は次のとおりです。

  1. 記事を1本公開終了にして、フィードを再生成する
  2. 生成されたXMLを開き、その記事のitemが残っていて media:status が deleted になっていることを確認する
  3. そのXMLをチェックツールに投入し、判定結果とプレビューを確認する

4-2. 確認項目

確認項目

内容

判定結果

ERRORが出ていない(WARNINGは残っていても問題ありません)

公開終了した記事のitem

フィードから消えずに残り、media:status が deleted になっている

プレビュー表示

該当のitemが赤背景で media:status deleted - <タイトル> と表示される

公開中の記事

プレビュー一覧にタイトル・日付・サムネイル付きで並んでいる

実際に投入したところ、いずれもこの状態になりました。判定はWARNINGのみでERRORはなく、プレビューでは公開終了した記事が赤背景で 『media:status deleted - 削除確認ダミー記事』 と表示されています。削除フラグがSmartNews側に正しく解釈されていることを確認できました。

4-3. WARNINGについて

取り下げに関係するWARNINGは次の2つです(ほかにロゴ画像や計測タグ未設定の指摘も出ますが、本題とは無関係なので省きます)。

指摘

内容

error generating SmartView preview

公開終了した記事は link 先が404になるため、content:encoded に本文があるとSmartViewが元ページを取得しに行って失敗します(本文が空の場合は生成処理自体が走らないためエラーになりません)

item.media:thumbnail のご指定を推奨しています

deleted item にサムネイルが入っていない場合に出ます

error generating SmartView preview は公開終了記事では避けられませんが、実害はありません。判定はWARNINGでERRORではなく、 media:status=deleted は「この記事を消してください」という指示であってSmartViewで読ませるためのitemではないためです。公開中の記事のプレビューは正常なので、通常の配信にも影響しません。

おわりに

マネジメントAPIを利用することで、記事を公開終了にするだけでSmartNewsからの取り下げまで自動で完結するようになりました。
運用担当者が「CMSで公開終了にしたあと、配信先の管理画面でも手動で取り下げる」という二重作業から解放され、取り下げ漏れによるトラブルも防げます。CMSの状態が配信物へ自動で反映される仕組みをつくれることで業務時間の削減に繋がり、空いた時間で別のタスクをこなせるようになります。

実装のポイントを振り返ります。

  • SmartNews専用フィードから消すのではなく、 media:status=deleted を立てて残す
  • 「どの記事がいつ終了したか」はマネジメントAPI、「中身」はコンテンツAPI
  • 削除フラグを残す期間は、公開終了日だけでなく公開日でも絞る

今回の実装を通して、「APIから取得できる情報」と「目的を達成するために必要な情報」をしっかり区別して把握しておく重要性を学びました。
マネジメントAPIで closedAt が取れると分かった時点で安心してしまいがちですが、削除フラグ付きのitemを作るにはコンテンツAPIからしか取得できない内容も必要です。
皆さんが複数のAPIを組み合わせて実装する際は、コードを書き始める前に「最終的に必要な情報が、どのAPIの、どんな条件で取得できるのか」をあらかじめ洗い出しておくことをおすすめします。

マネジメントAPIはベータ版のため、今後仕様が変更される可能性があります。各ニュースアグリゲーターの仕様も変動しますので、実装前に最新のドキュメントと一次情報をご確認ください。
「CMSの管理情報を配信物に正しく反映する」という考え方自体は、アグリゲーター連携に限りません。サイトマップから公開終了したURLを除外する用途にも、同じアプローチが使えるかと思います。

本記事は以上です。実装する際の参考になれば幸いです。

参考リンク

microCMS 採用情報

microCMSでは現在、カスタマーエンジニア・プラットフォームエンジニア・プロダクトエンジニアを積極採用中です!

ご興味のある方は、採用情報ページや弊社メンバーが業務内容やチームの雰囲気についてお話しているポッドキャスト「microCMS FM」をぜひチェックしてみてください。

カジュアル面談からでも大歓迎です。お気軽にご応募ください!

まずは、無料で
試してみましょう。

microCMSは無料ではじめられます。
ご不明な点はお気軽にお問い合わせください。