
アプリ(GeoConverterPro / GeoPrism JP)の宣伝用に、X(旧 Twitter)へ画像付き投稿を自動化しようとしたところ、POST /2/tweets が延々と 403 を返し続けました。エラー本文は次のとおりです。
{
"detail": "You are not permitted to perform this action.",
"type": "about:blank",
"title": "Forbidden",
"status": 403
}
この文言でネット検索すると「Pay-Per-Use プランのエンロール不具合」「有料化してもツイート作成権限が付与されないバグ」という情報が多数出てきます。私も最初はそれを疑いましたが、丸一日かけて切り分けた結果、真因はまったく別のところにありました。同じエラーで詰まっている方が多そうなので、切り分けの過程ごと共有します。結論だけ知りたい方は最後の「まとめ」へどうぞ。
環境
- X API v2、有料(Pay-Per-Use)プラン、アプリは Project 配下
- 認証:OAuth 1.0a ユーザーコンテキスト(consumer key/secret + access token/secret、権限は Read and write)
- 投稿処理:メディアを v1.1
media/uploadでアップロード →POST /2/tweetsにmedia_idsを添付 - 言語:Python(
requests+requests_oauthlib)
「エンロール不具合」に見えてしまう理由
厄介なのは、エンロール不具合の症状と見分けがつかない点です。
- GET 系エンドポイントは正常に認証される
- v1.1 のメディアアップロードは成功し、
media_id_stringも返ってくる - ダッシュボード上は Active、クレジットも残高あり
- なのに
POST /2/tweetsだけが 403
この状態だと「書き込み(ツイート作成)のエンロールが有効化されていないのでは」と考えるのが自然です。ですが、ここで手を止めずに変数を1つずつ潰すのが結果的に近道でした。
切り分け:容疑者を1つずつ消す
① 認証方式(OAuth 1.0a か 2.0 か)
「書き込みは OAuth 2.0 ユーザーコンテキストでないと通らない」という報告があったため、OAuth 2.0(PKCE、tweet.write スコープ付き)でも試しました。→ 結果は同じ 403。しかも 401(認証失敗)ではなく 403(認証は通るが権限なし)。認証方式は原因ではないと分かりました。
② トークンの取り違え
メディアアップロードとツイート作成で別々のトークンを使っていないか。→ コード上、同一の認証オブジェクトを両方に渡しており一致。別スクリプトにトークンをハードコードして単独で POST /2/tweets を叩くと、短いテキストなら 201 で成功しました。つまりトークンは正常で、書き込み権限自体は生きている。
③ 重複投稿(ここが分岐点)
同じ本文を投げ直すと 403 になりますが、そのときのエラーは別物でした。
{ "detail": "You are not allowed to create a Tweet with duplicate content.", ... }
重複は “duplicate content“。私が悩んでいた 403 は “not permitted to perform this action“。文言が違う=重複が原因ではない、と切り分けられます。X は重複をちゃんと専用メッセージで返してくるので、ここは判断材料になります。
④ 文字数・メディア・リンク
- 文字数:問題の投稿は 280 字未満。長さは無関係。
- メディア(画像):
--no-mediaで画像を外し、テキストのみにしても 403。画像も無罪。 - リンク URL:自前ドメイン(新規・低評価)の初出 URL でも、単体なら 201 で通る。リンクも無罪。
ここまでで、認証・トークン・重複・文字数・画像・リンクがすべて容疑から外れました。
⑤ 残ったのはハッシュタグ
同じ本文で、ハッシュタグを付けると 403 / 外すと 201 という差がきれいに再現しました。
本文 + #ジオイド #測量士補 #測地系 #GeoPrismJP → 403 not permitted
本文 + https://(自前ドメインのリンク) → 201 OK
つまり犯人はハッシュタグでした。より正確には「新規(低評価)アカウントが、ハッシュタグ付きの宣伝的な投稿を API から行う」という組み合わせが、X の自動スパム検知に引っかかり、POST /2/tweets を 403「not permitted to perform this action」で弾いていた、というのが実測からの結論です。
なぜ紛らわしいのか
この 403 が厄介なのは、次の3点が重なるためです。
- エラー文言が汎用的(「この操作は許可されていません」)で、原因を特定するヒントがゼロ。
- エンロール不具合の 403 とまったく同じ文言なので、検索すると誤診に誘導される。
- 確率的に振れる。ハッシュタグ付きでも稀に 201 で通ることがあり、「間欠不具合」に見えてさらに混乱する。スパム判定はアカウント評価など複数シグナルの総合スコアで、決定論的ではないためです。
高信頼ドメイン(例:apps.apple.com)のリンクだとハッシュタグ付きでも通りやすい、という傾向も観測しました。X の内部ヒューリスティックは非公開なので、あくまで経験則です。
対策:X ではハッシュタグを外す
確実に通す最小の変更は「X 投稿の本文からハッシュタグを除去する」ことでした。他の SNS(Threads 等)はこの制限が無いので、ハッシュタグは維持して構いません。プラットフォームごとに本文を出し分けます。
import re
def limit_hashtags(text: str, n: int) -> str:
"""本文中のハッシュタグを先頭 n 個だけ残して除去。n=0 で全除去。URL は触らない。"""
tag_re = re.compile(r'(?<!\S)#\S+')
if len(tag_re.findall(text)) <= n:
return text
kept = 0
def repl(m):
nonlocal kept
if kept < n:
kept += 1
return m.group(0)
return ""
out = tag_re.sub(repl, text)
out = re.sub(r'[ \t]+', ' ', out)
out = re.sub(r' *\n', '\n', out)
out = re.sub(r'\n{3,}', '\n\n', out)
return out.strip()
# X へ投稿する本文だけハッシュタグを外す(Threads 等はそのまま)
text_for_x = limit_hashtags(original_text, 0)
n を 1 や 2 にすれば「先頭のハッシュタグだけ残す」運用も可能です。ただし確実に通るのは 0(全除去)だったので、まずは 0 で安定運用し、アカウントの評価が育ってから 1〜2 に増やして様子を見るのが無難です。
まとめ
POST /2/tweetsの 403「You are not permitted to perform this action」は、必ずしも Pay-Per-Use のエンロール不具合ではない。- 重複投稿は “duplicate content” という別文言で返るので区別できる。
- 認証方式(OAuth1/2)・トークン・文字数・画像・リンクを1つずつ潰した結果、真因はハッシュタグ付き投稿に対する X 側のスパム判定だった。
- 対策は単純で、X 投稿の本文からハッシュタグを外す(他 SNS は維持)。高信頼ドメインのリンクは比較的通りやすい。
- スパム判定は確率的に振れるため、「たまに通る=間欠不具合」と誤認しないこと。
同じ 403 で消耗している方は、まずハッシュタグを外した短文を1回投げてみてください。それで 201 が返るなら、原因はエンロールではなくスパム判定側です。切り分けが一気に進みます。
なお、この検証は座標変換アプリ GeoConverterPro のX自動投稿化の副産物です。iPhone だけで JGD2024〜東京測地系、平面直角座標、CSV 一括変換までオフラインで完結するアプリなので、測量・GIS 関係の方はよければ覗いてみてください。
開発者より: アプリ・Kindle本・オープンソースの一覧は GitHub: amru195704 にまとめています。
お願い
本記事の情報は参考目的で掲載しており、正確性・完全性を保証するものではありません。誤記・不正確な情報がございましたら、コメント欄よりご指摘いただければ、確認のうえ修正いたします。
アプリを入手(App Store):GeoConverterPro(座標変換) | GeoPrism JP(測地系の可視化・学習)
コメントを残す