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?

Qiita CLI自動投稿で遭遇したWAF誤検知(403)とタイトル重複(422)の解決策

0
Last updated at Posted at 2026-07-19

はじめに

GitHubへのプッシュをトリガーに、Zenn CLIやQiita CLIを使って技術記事を自動投稿・同期する仕組みは、開発者にとって非常に便利なワークフローです。しかし、CLIからAPI経由で投稿を行う場合、ブラウザからの手動投稿では遭遇しない特有のエラーに直面することがあります。

本記事では、Qiita CLIを利用した自動デプロイにおいて、実際に遭遇した以下の2つのエラーの原因、背景、およびその解決策について解説します。

  1. WAF(Web Application Firewall)による誤検知(403 Forbidden
  2. エラー中断時のID未同期による競合(422 Unprocessable Entity

1. トラブル1:Qiita APIからの 403 Forbidden (WAF誤検知)

現象

GitHub Actions等から qiita publish コマンドを実行した際、特定の記事のみ以下のようなエラーが発生し、投稿が拒否される現象です。

QiitaForbiddenOrBadRequestError: {"message":"Forbidden","type":"forbidden"}
  at Object.publish

アクセストークン自体は他の記事の投稿に成功しているため有効であり、権限(スコープ)も正しく設定されているのにもかかわらず、特定の内容を含む記事だけが 403 で弾かれます。

原因

記事の本文中に、「長いシェルスクリプトのワンライナー」や「インラインのPythonスクリプト」など、コマンド実行に関わるコードブロックが含まれていたことが原因です。

Qiitaのサーバー側で稼働しているWAF(Web Application Firewall)またはセキュリティフィルターが、リクエスト(投稿内容のJSONデータ)の解析時に、これらのスクリプト記述を「OSコマンドインジェクション」や「不正なコード挿入」といった攻撃パターンであると誤検知し、APIレベルでアクセスを拒否(403 Forbidden)していました。

特に、以下のような特徴を持つ文字列がJSONリクエスト内にエスケープされた状態で含まれていると、誤検知を誘発しやすくなります。

  • python -c "import sys, json; ..." のようなPythonワンライナー
  • パイプ(|)やリダイレクト(<<<)、変数の展開($file)が複雑に組み合わさったシェルスクリプト
  • エスケープされたダブルクォーテーション(\")の多用

解決策

WAFに「危険なリクエスト文字列」と判定されないよう、記事内のコードの記述方法をリファクタリングして回避します。

設定ファイルの記述例の中に複雑なワンライナーを直接書くのではなく、外部スクリプトファイルにロジックを分離し、設定ファイル側からはそのスクリプトを呼び出すだけのシンプルな記述に変更します。

【修正前】(WAFに弾かれる例)

"hooks": {
  "PostToolUse": [
    {
      "type": "command",
      "command": "file=$(PYTHONUTF8=1 python -c \"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input', {}).get('file_path', ''))\" 2>/dev/null); if echo \"$file\" | grep -qiE '\\.kt$'; then full=$(./gradlew compileKotlin --console=plain 2>&1); status=$?; output=$(printf '%s\\n' \"$full\" | tail -40); if [ \"$status\" -ne 0 ]; then PYTHONUTF8=1 python -c \"import json,sys; print(json.dumps({'decision':'block','reason':sys.stdin.read()}))\" <<< \"$output\"; else echo \"$output\"; fi; fi",
      "shell": "bash"
    }
  ]
}

【修正後】(WAFを回避する構成)

設定ファイル側の記述を極限までシンプルにし、スクリプトの実体を別のコードブロック(ファイル)として紹介します。

  • 設定ファイルの記述例

    "hooks": {
      "PostToolUse": [
        {
          "type": "command",
          "command": "bash \"$HOME/.claude/hooks/kotlin-compile-check.sh\"",
          "shell": "bash"
        }
      ]
    }
    
  • スクリプトの実体(kotlin-compile-check.sh

    #!/bin/bash
    file=$(PYTHONUTF8=1 python -c "import json,sys; d=json.load(sys.stdin); print(d.get('tool_input', {}).get('file_path', ''))" 2>/dev/null)
    # (中略)
    

この記述方法の変更により、WAFの誤検知を確実に回避できます。また、読者にとってもシェルスクリプトが独立しているため「真似しやすく読みやすい技術記事」になるという二次的なメリットも得られます。


2. トラブル2:422 Unprocessable Entity (タイトル重複エラー)

現象

WAFエラーの対処を行い、再度デプロイ(qiita publish)を試みた際、今度は以下のようなエラーが発生してデプロイが失敗する現象です。

QiitaUnprocessableEntityError: {"message":"title has already been used. Please choose a different title.","type":"unprocessable_entity"}

原因

QiitaのAPI仕様上、**「以前のエラー発生時に、Qiita側では記事の新規作成自体は成功していた(限定共有記事として登録されていた)」**ことが原因です。

  1. 最初のデプロイ時、Qiita CLIは id: null で新規投稿リクエストを送信。
  2. Qiita側は記事を新規作成し、IDを発行(この段階でデータベースにタイトルが登録される)。
  3. しかし、その直後に前述のWAFエラー等の別要因でプロセスが強制終了(クラッシュ)する。
  4. デプロイが異常終了したため、Qiita CLIが発行されたIDをローカルファイル(public/*.md)のメタデータに書き戻す処理(同期)が実行されない。
  5. ローカルにはIDが保存されず、フロントマターの id: null が維持される。
  6. 修正後に再度デプロイを実行すると、ローカルは依然として id: null なので「新規投稿」を試みるが、Qiita側には前回のクラッシュ時にすでに同一タイトルの記事が作成されているため、「タイトル重複エラー」として却下される。

※Qiitaでは公開・非公開(限定共有)に関わらず、同一アカウント内でのタイトル重複が制限されています。

解決策

すでにQiita側に作成されてしまっている「宙に浮いた記事」のIDを取得し、ローカルファイルのメタデータ(フロントマター)と手動で同期させ、新規作成ではなく「上書き更新(update)」としてAPIを叩かせる必要があります。

対策手順

  1. 一時的なPullステップの追加
    ローカル側に認証情報がない場合、GitHub ActionsなどのSecrets(QIITA_TOKEN)が使える環境で一時的に npx qiita pull コマンドを実行させるステップをワークフローに追加し、変更結果を自動でコミット&プッシュさせます。

    - name: Pull existing articles from Qiita
      run: npx qiita pull --root .
      env:
        QIITA_TOKEN: ${{ secrets.QIITA_TOKEN }}
        
    - name: Commit pulled articles
      run: |
        git config user.name "github-actions[bot]"
        git config user.email "github-actions[bot]@users.noreply.github.com"
        git add public
        git diff --staged --quiet || git commit -m "chore: pull existing articles"
        git push
    
  2. IDの取得とマッピング
    上記を実行すると、Qiita上にある記事がローカルの public/ ディレクトリに同期されます。

    • 新規に public/<Qiita側で作成されていたID>.md というファイルが降ってきます。
    • このファイル名(ID)を確認し、対応するローカルの記事ファイル(public/<記事のベース名>.md)のフロントマターにある id フィールドに、そのIDを転記します。
      # 例
      id: "12da2d124c28dad2cc89"
      
    • 同期のために生成された不要なテンポラリファイル(public/<ID>.md)を削除します。
  3. ワークフローの復元と再プッシュ
    一時的に追加した pull ステップをワークフローファイルから削除し、IDを同期したメタデータファイルと共にリポジトリへ push します。
    以降はIDが一致するため、正常に上書き更新(update)が走るようになります。


まとめ:Qiita CLI自動運用時のベストプラクティス

Qiita CLIとGitHub Actionsを組み合わせた自動運用システムでは、以下の点に注意することでデプロイ時の不意なエラーを未然に防ぐことができます。

  • 安全なマークダウン記述: 記事内に複雑なコードや環境変数、ワンライナーを記載する際は、WAFに引っかからないよう外部スクリプトに逃がすか、エスケープ表現に気を配る。
  • エラー発生時のID管理: デプロイが一度でもエラーで中断した場合は、Qiita上に記事だけが作成されていないかを確認する。作成されている場合は、速やかにローカルファイルに記事IDをマッピングさせておく。
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?