背景
自動投稿の仕組みで、記事が1本も出ない日が出ました。
エラーは出ていません。
ログは「作りません」「昇格しません」と丁寧に理由を書いて、正常終了している。
2つのガードがそれぞれ正しく働いた結果、出口が塞がっていた、というやつです。
今日その修正をしたので、書いておきます。
何が起きていたか
仕組みは単純です。
朝に下書きを pending/ へ作って、夜にそれを各媒体へ公開する。
公開したら記録を published.json に積む。
この話の前提として、冪等という言葉を先に置いておきます。
冪等とは「同じ操作を何回繰り返しても結果が変わらない」性質のことで、自動投稿の文脈では「同じ記事を二度出さない」ことを指します。
そのために「この記事はもう出したか」を判定するキーが要る。
今回壊れたのは、そのキーの選び方でした。
ガードが2つあります。
ひとつめ。未公開の下書きが残っている媒体には、新しい下書きを作らない。
滞留したまま下書きだけ増えるのを止めるための決まりです。
ふたつめ。過日の下書きを当日へ昇格するとき、1媒体でも公開済みのファイルは昇格しない。
昇格するとファイル名の日付が変わり、slug + date で見ている冪等性のキーが変わってしまうので、成功済みの媒体まで再投稿してしまう。
それを防ぐためのものです。
ここで、こういう状態になりました。
- 9/19 の朝に下書きが4本できた。品質ゲートに引っかかって公開されず、
pending/に残った - その日、別の題材の記事が4本公開された。ファイル名の付け方が同じなので、slug も同じになった
- 翌日。ひとつめのガードが「未公開が残っている」と判断して新規生成を止める
- ふたつめのガードが「この slug+date は公開済みだ」と判断して昇格を止める
生成も公開もされない状態が固定されます。
片方だけなら翌日に解けるのですが、互いの前提が相手の出力なので、自力では抜けられない…。
実際のログはこうでした。
[daily-draft] 未公開の過日下書きが残っているため、次の媒体は当日分を作りません: zenn/qiita/hatena/blogger。
[daily-publish] 過日下書きを当日へ昇格しません(二重投稿防止): hatena へ公開済みの部分成功ファイル
[daily-publish] 本日(2026-09-20)は下書きが1件も無く、公開実績もゼロです。
原因は「キーが一意でなかった」こと
昇格の可否を判定していたのは、この関数です。
base は pending/ のファイル名(2026-09-19-fleet-devlog-cve-watch-qiita.md のような形)、registry は published.json を読んだ配列です。
function stalePromotable(base, registry) {
const m = String(base).match(/^(\d{4}-\d{2}-\d{2})/);
if (!m) return { ok: false, reason: "先頭が YYYY-MM-DD でない", okPlatforms: [] };
const date = m[1];
const slug = String(base).replace(/\.md$/, "");
const okPlatforms = [...okPlatformsFor(registry, slug, date)];
if (okPlatforms.length)
return { ok: false, reason: `${okPlatforms.join("/")} へ公開済み`, okPlatforms };
return { ok: true, reason: "", okPlatforms: [] };
}
okPlatformsFor() は registry から slug と date が一致する記録を拾って、成功した媒体名の集合を返します。
空でなければ「この下書きはもう一部が出ている」と判断して止める。
ここが間違いでした。
slug + date は、記事を一意に指していない。
slug の付け方が <日付>-fleet-devlog-<サイト名>-<媒体名> という決まりなので、同じ日に同じサイトの話を書けば、題材が違っても同じ slug になります。
つまり「slug+date が一致した」は「同じ記事だ」を意味しない。
未公開の別記事が、他人の公開記録を見て「自分は公開済み」と判定していた、というのが正体です。
タイトルで突き合わせる
published.json の各レコードには title が入っていました。
なので、そこを比べれば別記事だと分かります。
function stalePromotable(base, registry, draftTitle) {
// ...(date と slug の取り出しは同じ)
const okPlatforms = [...okPlatformsFor(registry, slug, date)];
if (okPlatforms.length) {
const mine = normalizeTitle(draftTitle); // 空白を落として比較用に正規化
const recorded = titlesFor(registry, slug, date); // その slug+date に記録されたタイトル群
if (mine && recorded.length && !recorded.includes(mine))
return { ok: true, reason: "slug は衝突しているがタイトルが別", okPlatforms: [], differentArticle: true };
return { ok: false, reason: `${okPlatforms.join("/")} へ公開済み`, okPlatforms };
}
return { ok: true, reason: "", okPlatforms: [] };
}
draftTitle は下書きの front-matter から読んだ title です。
呼び出し側では、すでに front-matter をパースしていたので、その meta.title を渡すだけで済みました。
意図的に保守側へ倒してあります。
draftTitle が空のとき、記録側に title が無いとき、どちらかが欠けたら従来どおり止める。
判断材料が足りないときに通してしまうと、今度は本当に二重投稿します。
安全側のデフォルトは動かさない、という方針です。
これだけでは抜けられなかった
ここで一度「直った」と思ったのですが、追いかけると抜けていませんでした。
昇格はファイル名の先頭の日付を当日へ差し替える処理です。
2026-09-19-fleet-devlog-cve-watch-qiita.md は 2026-09-20-... になる。
ところが、その 2026-09-20-... という slug は、当日すでに公開された別記事が使っている。
昇格したあと、公開対象の媒体を決めるところで okPlatformsFor(新しい slug, 当日) を引くと、他人の成功が返ってきます。
公開すべき媒体が空集合になって、何も出ないまま pending/ に残る。
そして翌日、そのファイルは「部分成功ファイル」として扱われる。
同じデッドロックを作り直していたわけです…。
なので、名前を付けるところも直しました。
function promotedName(basename, todayKey, taken) {
const renamed = String(basename).replace(/^\d{4}-\d{2}-\d{2}/, String(todayKey));
const used = taken instanceof Set ? taken : new Set(taken || []);
if (!used.size) return renamed;
const stem = renamed.replace(/\.md$/, "");
if (!used.has(stem)) return renamed;
for (let i = 2; i <= 9; i++) {
const suffix = `-${i}`;
const cut = stem.slice(0, Math.max(1, 50 - suffix.length)); // Zenn の slug 上限に収める
if (!used.has(`${cut}${suffix}`)) return `${cut}${suffix}.md`;
}
return renamed;
}
taken には、published.json のうち当日日付のレコードの slug を集めて渡します。
衝突していなければ今までどおり日付だけ差し替えるので、既存の挙動は変わりません。
50 - suffix.length で切っているのは、Zenn の slug が 12〜50 文字という制約を持っているためです。
接尾辞を足して 51 文字になると、今度は公開のほうで落ちる。
テストが教えてくれたこと
この修正を入れたら、既存のテストが1本落ちました。
「日付以外が同名の滞留下書きが2件あっても、昇格するのは1件だけ」というものです。
私は接尾辞で衝突を避ける処理を、公開済み記事との衝突にも、昇格待ちどうしの衝突にも適用していました。
結果、2件とも別名で昇格して、同じ媒体へ2本流れることになっていた。
避けるべき衝突と、避けてはいけない衝突があるという話です。
公開済みの別記事とは名前を分ける。
昇格待ちどうしは、1件ずつ順番に出す(=2件目はスキップして翌日に回す)。
結局、taken に積むのは published.json 由来の slug だけにして、昇格待ちの分は従来どおり後勝ちでスキップさせました。
テストが無かったら、たぶん気づかずに入れていました…。
追加したテストは6本です。
タイトルが違えば昇格する、タイトルが同じなら止まる、タイトルを渡さなければ止まる、記録側に title が無ければ止まる、接尾辞が付く、接尾辞を付けても 50 文字に収まる。
このサイトの一覧は https://cve.autoarticles.net にあります。
持ち帰り
冪等性のキーを決めるときは、そのキーが単体で対象を一意に指せるかを確かめてください。
slug + date は一見それらしく見えますが、slug の生成規則に日付が入っていて、かつ日付をずらす処理が同居していると、一意性は簡単に壊れます。
そしてガードを足すときは、そのガードが閉じたあとに出口が残っているかを確認すること。
片方向のガードは翌日に解けますが、互いの前提が相手の出力になっている2つは、外から手を入れるまで解けません。
本記事はAI補助で執筆した、個人開発の紹介記事です。