0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptで予約投稿の二重集計を防ぐ

0
Last updated at Posted at 2026-10-16

投稿管理ダッシュボードの更新では、公開済みの投稿を追加するだけでなく、前回は予約中だった投稿の状態も置き換えました。同じ記事が公開されたなら、予約と公開の2件ではなく、公開済みの1件として数える必要があります。

今回の運用では、取得した現在の一覧を更新し、画面はその一覧から公開件数と予約件数を分けて集計しています。前回と今回の一覧をそのまま足す方式にはしませんでした。

この記事では、この更新ルールをJavaScriptの小さな例に切り出します。ポイントは重複を消す書き方よりも先に、入力が「現在の全量」なのか「前回からの差分」なのかを決めることでした。

予約が公開へ変わったとき、何を数えるか

自分が使っているダッシュボードには、投稿の公開状況と、今後の予約を表示しています。更新前後のデータを照合すると、同じ記事URLのまま、状態が予約から公開へ変わっているものがありました。

画面側の集計は、公開済みならpublished、予約中ならscheduledで絞り込むだけです。ここで古い予約レコードを残したまま公開レコードを追加すると、画面の集計式が正しくても入力の時点で二重になります。

実データから離れた例で考えます。以下のIDと内容は記事用の架空データです。

const before = [
  { id: 'post-a', status: 'scheduled' },
  { id: 'post-b', status: 'published' },
];

const latest = [
  { id: 'post-a', status: 'published' },
  { id: 'post-b', status: 'published' },
  { id: 'post-c', status: 'scheduled' },
];

const appended = [...before, ...latest];

appendedでは、公開済みのpost-bも重複しています。予約から公開への変更だけが問題なのではなく、全量の一覧を差分として追加したことが原因です。

集計対象 公開済み 予約中 合計
更新前の一覧 1 1 2
今回確認した一覧 2 1 3
単純に連結した一覧 3 2 5

今回知りたいのは現在の公開状況なので、集計に使うのは2行目の3件です。前回の状態は、必要なら履歴として別に残します。現在の件数を出す配列へ混ぜるものではありません。

日時やタイトルではなく、同じ投稿を指すキーで照合した

元のデータでは、記事URLを照合すると、同じ記事の状態変更だと確認できました。サンプルではその識別部分をidに置き換えています。

タイトルは変更される可能性があり、予約日時も変更できます。同じ時刻に複数の投稿を予約する場合もあるので、それだけで同じ投稿だとは判断できません。

この例が前提にしているのは、予約中から公開後まで追跡できるキーがあることです。予約管理側のIDと公開後のIDが別になるサービスでは、確認できる対応情報が必要です。日時が近いという理由だけで、自動的に同じ投稿へまとめる処理は含めません。

また、複数のサービスやアカウントを集計するときは、その取得範囲も区別します。別サービスで同じID文字列が使われても、同じ投稿にはなりません。

現在の一覧を検査してから置き換える

元のダッシュボードは、確認した一覧をデータファイルへ反映する構成です。以下はその運用ルールを、検証できる関数として表した記事用の例です。取得処理そのものを実装したものではありません。

入力は、同じサービス・アカウント・対象範囲について確認した全量スナップショットとします。ここでは取得範囲をscope、取得が完了したことをcompleteで渡します。

// snapshot.mjs
export function compileSnapshot(input, expectedScope) {
  if (input.scope !== expectedScope || input.complete !== true) {
    throw new Error('Snapshot scope is wrong or collection is incomplete');
  }
  if (typeof input.fetchedAt !== 'string' ||
      !Number.isFinite(Date.parse(input.fetchedAt)) ||
      !Array.isArray(input.records)) {
    throw new Error('Invalid snapshot');
  }

  const byId = new Map();
  for (const row of input.records) {
    if (!row || typeof row.id !== 'string' || !row.id.trim() ||
        !['published', 'scheduled'].includes(row.status)) {
      throw new Error('Invalid record');
    }
    if (byId.has(row.id)) throw new Error('Duplicate record ID');
    byId.set(row.id, { id: row.id, status: row.status });
  }

  return {
    scope: input.scope,
    fetchedAt: input.fetchedAt,
    records: [...byId.values()],
  };
}

export function countStatus(snapshot) {
  return {
    published: snapshot.records.filter(r => r.status === 'published').length,
    scheduled: snapshot.records.filter(r => r.status === 'scheduled').length,
  };
}

使う側では、検査済みの一覧ができてから現在の状態を更新します。

const next = compileSnapshot({
  scope: 'blog:all',
  complete: true,
  fetchedAt: '2026-10-16T09:00:00+09:00',
  records: latest,
}, 'blog:all');

current = next;
console.log(countStatus(current));
// { published: 2, scheduled: 1 }

ここでMapは、同じIDが入力に2回現れていないかを調べるために使っています。同じキーへset()すると値が更新される性質はありますが、今回はそれで黙って片方を採用しません。

1回の全量取得の中に、同じIDの予約と公開が混ざっていたら、取得元の状態やページをまたぐ取得タイミングを調べる対象にします。配列で後ろにある方が新しいとは限らないためです。記事用の例では、まず不整合を検出し、再取得や確認へ戻せるようにしています。

この例の関数はIDと状態だけを扱います。タイトル、日時、公開先などを表示するアプリでは、必要な項目を別途検証して保持します。外部サービスの認証や、レスポンス全体のスキーマ検証もこの関数の責任には含めていません。

空の一覧と、取得できなかった一覧を分ける

全量で置き換える場合に気になったのは、取得に失敗した結果を空配列として採用しないことです。

認証が切れていた、途中のページを取得できなかった、予約一覧だけを確認できなかった。その状態を「投稿0件」として扱うと、前回確認できた表示まで消えてしまいます。

completeは、単にHTTPリクエストが成功したら付けるフラグではありません。取得側でログイン状態、必要なページ、公開と予約の両方など、対象範囲を確認したうえで付ける契約です。この関数だけでは取得漏れを証明できません。

元の運用でも、再ログインが必要ならデータを上書きせず、最後に成功したサイトを維持する方針です。これを自動化する場合は、古い一覧を残すだけでなく、最終取得時刻と更新失敗を画面へ示す設計にすると、最新情報と誤解されにくくなります。上の最小関数には、その表示処理までは含めていません。

一方、取得が完了し、本当に対象が0件だったなら、空配列への更新を認めます。

対象範囲が「直近3か月」のように動く場合も同じです。新しい投稿を足すだけでは、期間外になった古い投稿が残ります。今回の対象範囲の全量で更新するなら、範囲外になったものも現在の一覧から外せます。元サービスから投稿を削除する話ではありません。

予定時刻を過ぎても、公開済みにはしない

予約日時が過ぎたことと、実際に公開されたことは別です。予約の変更や取り消し、公開処理の失敗があれば、予定どおりの状態にはなりません。

そのため、今回の集計例には「現在時刻を過ぎていたらpublishedに変える」という分岐を入れていません。公開状況の取得で確認した状態を使います。確認できていないものは、時刻から公開成功を補って数えません。

取得時刻を残しているのも、画面の件数がいつ確認した状態なのかを分けるためです。予約があることを示す画面と、その投稿が公開されたことを示す画面では、必要な根拠が違いました。

架空データで更新前後をテストする

確認したかったのは、予約が公開へ変わったあとに1件として数えられることと、不完全な入力で現在の状態を失わないことです。

たとえば、次のテストはsnapshot.mjsと同じフォルダーへ保存して実行できます。

// snapshot.test.mjs
import assert from 'node:assert/strict';
import test from 'node:test';
import { compileSnapshot, countStatus } from './snapshot.mjs';

const batch = (records, complete = true) => ({
  scope: 'blog:all',
  complete,
  fetchedAt: '2026-10-16T09:00:00+09:00',
  records,
});

test('予約から公開へ変わっても1件として数える', () => {
  const latest = [
    { id: 'post-a', status: 'published' },
    { id: 'post-b', status: 'published' },
    { id: 'post-c', status: 'scheduled' },
  ];
  const next = compileSnapshot(batch(latest), 'blog:all');
  assert.equal(next.records.length, 3);
  assert.deepEqual(countStatus(next), { published: 2, scheduled: 1 });
});

test('取得途中なら前回の一覧を残す', () => {
  let current = compileSnapshot(batch([
    { id: 'post-a', status: 'scheduled' },
  ]), 'blog:all');
  const previous = current;
  assert.throws(() => {
    current = compileSnapshot(batch([], false), 'blog:all');
  });
  assert.strictEqual(current, previous);
});
node --test snapshot.test.mjs

この2件に加え、記事用の検証では、単純連結による二重集計、同じIDの重複、完全な空配列、取得範囲の取り違え、不正な入力も確認しました。Node.js v24.19.0で計7件のテストを実行し、すべて成功しています。外部サービスへアクセスした結果ではなく、集計と置き換え条件を架空データで確かめたものです。

元のデータ更新についても、変更前後を記事URLで照合し、予約から公開への切り替えと、更新後のキーの重複がないことを確認しました。記事の最小関数を、そのまま元のアプリへ追加したわけではありません。

まとめ

今回の更新では、予約と公開を別々に増える履歴として扱わず、同じ投稿の現在の状態として整理しました。

全量の一覧なら、前回分へ追加せず、取得範囲と完了を確認して置き換える。差分取得なら、識別子による更新や削除の扱いを別に設計する。この区別がないまま、重複を消す処理だけを足しても、どちらの状態を残すかは決まりません。

自分のダッシュボードでは、「いま何件公開され、何件予約されているか」を確認したかったので、現在の一覧と過去の状態を混ぜないことを優先しました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?