🔗 この記事は https://blog.tak3.jp/ja/blog/qiita-403-tags/ からの転載です(一次情報源)。
以前、このブログの構成を書いた記事で、外部配信について正直に打ち明けた。「Qiita への自動投稿は 403(Forbidden)で止まっている。トークンのスコープ不足でもレート制限でもなく、アカウント単位で API 経由の記事作成が拒否されている状態だ」と。
結論から言う。これは間違いだった。 アカウントは制限などされていなかった。真犯人はもっと地味で、もっと自分のミスに近かった — タグ名に入れた半角スペースだ。本稿はその切り分けの記録である。
症状 — 3つの食い違い
配信サブシステム(Cron で回る別 Worker)は、記事の syndication.qiita が enabled/republish のとき Qiita へ POST/PATCH する。ある日、3つのことが噛み合わなくなった。
- 新規投稿できた記事と、できない記事があった。 ある記事は POST が通り、別の記事は 403 で弾かれる。
-
republishにした記事を編集しても、Qiita 側に反映されない。 更新の PATCH が毎回 403 で落ちていた。 -
エラーは一貫して
{"message":"Forbidden","type":"forbidden"}。 403 で、しかも「forbidden」。誰がどう読んでも権限の問題に見える。
3 のせいで、最初に疑ったのはアカウントかトークンだった。前回の記事に「アカウント制限」と書いたのはこのためだ。だがこの見立ては、記録を読み直したら崩れた。
配信履歴を読む — 「成功」なのにエラーが残っている
この配信システムは、投稿の結果を D1(SQLite)の履歴テーブルに残している。冪等キーは (target, article_id)。当該記事の行を引くと、こうなっていた(一部マスク)。
target = qiita
article_id = introducing-basou:ja
status = success
external_id = 2219…ea02 ← 初回 POST は成功して記事は存在する
source_sha = 93b4…0e24 ← 最新の本文 sha まで前進している
attempt = 2
error = qiita 403 Forbidden — {"message":"Forbidden","type":"forbidden"}
status は success なのに、error に 403 が残り、attempt は 2。この一見矛盾した状態が、実は正しく設計どおりの痕跡だった。
配信 Worker は、更新(PATCH)が 非 retryable な 4xx(401/403/404/422 など)で失敗したとき、こう振る舞う。
- ステータスを
successに戻す(初回投稿自体は生きているので) -
source_shaを最新へ前進させる(=「この本文についてはもう諦めた」の印) - エラー本文を記録する
- 同じ本文では二度と再試行しない
429 やネットワークエラーのような一時的な失敗は sha を据え置いて次の Cron で再試行するが、4xx は「直らない」と判断して諦める設計だ。だから履歴には「初回は成功・その後の更新は 403 で恒久失敗・本文 sha は前進済み」という、まさにこの通りの姿が残っていた。Worker はバグっていなかった。 Qiita が 403 を返し、それを受けて設計どおり諦めていただけだ。
「Forbidden」に騙された
ここで見落としていた事実がひとつある。同じトークンで、POST は成功していた記事があった。 初回投稿は通り、その後の更新 PATCH だけが 403 になる。もしトークンのスコープ不足やアカウント凍結なら、POST も PATCH も等しく落ちるはずだ。片方だけ通るのは、静的な権限の問題では説明がつかない。
403 の type: "forbidden" は、いかにも権限を指しているように読める。だが実際には、Qiita はリクエストの内容が制約に反したときにも 403 を返していた。エラーメッセージは、原因の在り処について嘘をついていたわけだ。
真犯人 — Qiita はタグ名の空白を区切りとして扱う
手動で Qiita のダッシュボードから同じ記事を更新しようとして、ようやく気づいた。タグの保存で弾かれる。
Qiita のタグには2つの制約がある。
- タグは最大5個。
- タグ名の半角(および全角)スペースは、タグの区切りとして扱われる。
つまり Claude Code という1つのタグは、Qiita の中では Claude と Code の2つに分裂する。ここに、さきほど履歴に出てきた記事(introducing-basou:ja)のタグを当てはめてみる。
| frontmatter のタグ | Qiita 換算 |
|---|---|
AIエージェント |
1 |
AIコーディング |
1 |
ハーネスエンジニアリング |
1 |
Claude Code |
2(Claude / Code) |
Codex |
1 |
| 合計 | 6 → 上限 5 超過で拒否 |
配信 Worker はタグを「最大5個」に切り詰めてはいた。ただし数えていたのはタグ文字列の個数であって、Qiita がスペースで分割したあとの個数ではなかった。Claude Code を1個と数えて5個に収めても、Qiita 側では6個に膨らんで上限を超える。これが 403 の正体だった。
最高に皮肉だったのは、前回の「配信パイプラインを解説した記事」自身が、まさにこれで自動投稿に失敗していたことだ。あの記事のタグはこうだった。
| frontmatter のタグ | Qiita 換算 |
|---|---|
Astro |
1 |
Sveltia CMS |
2 |
Cloudflare Workers |
2 |
i18n |
1 |
| 合計 | 6 → 拒否 |
4つのつもりが、Qiita 換算では6つ。配信の自動化を説明する記事が、その配信先のタグ規則に足を掬われて手動投稿になっていた。うまく自動投稿できていた記事は、たまたまタグが少なく、分割しても5個以内に収まっていただけだった。
直し方 — 分割ではなく、畳む
修正は正規化関数の1行だった。従来は前後の空白を落とす trim() だけ。これを、タグ名の内部空白まで除去するように変えた。
// 修正前: 前後の空白しか落とさない → "Claude Code" がそのまま渡り Qiita で2分裂
const name = t.trim();
// 修正後: 内部空白も含めて全除去 → "Claude Code" → "ClaudeCode"(全角空白も \s で対象)
const name = t.replace(/\s+/g, '');
とりうる手は他にもあった。空白で分割して Claude と Code の2タグにする案だ。だがこれは Code や CMS、Workers といった、単体では意味をなさないタグを量産する。そこで畳む方を選んだ — Claude Code → ClaudeCode、Sveltia CMS → SveltiaCMS。1つの frontmatter タグが、必ず1つの Qiita タグに対応する。こうすれば上限のカウントも畳んだ後に効くので、Qiita 換算でも確実に5個以内に収まる。手作業で直したときに自分が選んだ表現(ClaudeCode)とも一致した。
ブログ自身のタグページは Claude Code のような空白入りのタグを問題なく扱える。配信先の Qiita は扱えない。同じ「タグ」でも、表示する場所ごとに正規化のルールが違う。 配信レイヤーは、その差を吸収する責務を持つべきだった。
学んだこと
-
エラーメッセージは原因の在り処について嘘をつく。
403 Forbiddentype: forbiddenは権限を指しているように見えて、実際は入力内容の制約違反だった。ステータスコードと文言だけで原因を決めつけると、この記事の前半のように数日回り道する。 - 手元に残る記録が、切り分けを可能にした。 「成功なのに 403、attempt=2、sha は前進済み」という D1 の一行が、「Worker は諦めただけで壊れてはいない」「Qiita 側が拒否している」という切り分けを与えてくれた。ログとステータスがベンダーの管理画面の奥ではなく手元にあること自体が、デバッグの前提だった。
- 「直らない失敗」の扱いは諸刃だ。 403 を恒久失敗として諦める設計は、4xx の無限リトライを防ぐ一方で、原因が直っても本文が変わるまで再試行しない。恒久失敗として諦めたレコードは、直したあとに手で起こし直す必要がある。
- 外部プラットフォームの制約は、ドキュメントより実測で分かることがある。 タグの空白分割は、手動保存が弾かれて初めて腑に落ちた。
配信の自動化は、うまく動いているときほど何も語らない。止まったときに初めて、その内側の設計と、外の世界の理不尽さの両方が見える。今回はその両方を、正直に書いておく。