<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>この記事は<strong>固有情報を隠す</strong>ため、<code><PI_HOST></code>, <code><PI_USER></code>, <code>https://example.com</code> など<strong>プレースホルダ</strong>で書いています。コピペ後に自分の値へ置換してください。</p>
</blockquote>
<h3 class="wp-block-heading">Xへの自動投稿を“ちゃんと”作る</h3>
<h2 class="wp-block-heading">― OAuth 2.0 PKCE、127.0.0.1コールバック、トークン管理、メディア添付、SSHトンネルまで</h2>
<ul class="wp-block-list">
<li><strong>投稿だけ</strong>なら必要スコープは <code>tweet.write</code> + <code>users.read</code>(多くのエンドポイントで <code>tweet.read</code> も併記) <a href="https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>認可は <strong>OAuth 2.0(Authorization Code with PKCE)</strong>。認可URLは <code>https://x.com/i/oauth2/authorize</code>、トークンは <code>https://api.x.com/2/oauth2/token</code>。</li>
<li><strong>コールバックに <code>localhost</code> は使わない</strong>。代わりに <strong><code>http(s)://127.0.0.1</code></strong> を使う(App設定にも同じURLを許可登録)。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://developer.x.com/ja/docs/basics/apps/guides/callback-urls?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">Developer X</a></li>
<li>投稿は <code>POST https://api.x.com/2/tweets</code>。Bearerでユーザーアクセストークンを送る。 <a href="https://docs.x.com/x-api/posts/create-post" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>メディアは v2 の <strong><code>/2/media/upload</code>(INIT/APPEND/FINALIZE)</strong> でアップロード→<code>media_ids</code> を <code>POST /2/tweets</code> に添付。 <a href="https://docs.x.com/x-api/media/quickstart/media-upload-chunked?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>トークンは<strong>短命(<code>expires_in</code>)</strong>。<code>offline.access</code> を付けて<strong>リフレッシュトークン</strong>を貰って自動更新しよう。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">OAuth 2.0 Simplified</a></li>
<li><strong>TLS 1.2必須</strong>。クライアントのルートストア更新も忘れずに。 <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<h2 class="wp-block-heading">1. 全体図</h2>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">flowchart LR
subgraph Client["自動投稿クライアント(Python)"]
A[PKCE生成] --> B[認可URL生成]
B -->|ブラウザ起動| C[ユーザログイン]
E[127.0.0.1:8000 ローカルHTTPサーバ] --> F[認可コード受領]
F --> G[トークン交換 /2/oauth2/token]
G --> H[投稿 /2/tweets]
H --> I[結果保存・再試行制御]
end
subgraph X["X Platform"]
XAuth[Auth Server]:::cloud
XAPI[REST API]:::cloud
end
C-->XAuth
E<--XAuth
G-->XAuth
H-->XAPI
classDef cloud fill:#eef,stroke:#99f
</pre></div>
<h2 class="wp-block-heading">なぜ <code>127.0.0.1</code> で、<code>localhost</code> はダメなの?</h2>
<p>Xの公式ガイダンスに**「localhostはコールバックURLに使わないで。代わりに <code>http(s)://127.0.0.1</code> を使って」<strong>と明記されています。さらに</strong>App設定(許可リスト)と実際に投げる <code>redirect_uri</code> は完全一致**である必要があります。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://developer.x.com/ja/docs/basics/apps/guides/callback-urls?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">Developer X</a></p>
<p><strong>ネットワーク的背景:</strong></p>
<ul class="wp-block-list">
<li><code>localhost</code> はOSの名前解決に依存し、開発環境によっては <code>::1</code>(IPv6)へ解決され、ブラウザ↔ローカルサーバ間でアドレス不一致が起こりがち。</li>
<li><code>127.0.0.1</code> はIPv4の<strong>ループバック</strong>(OSカーネル内で折り返す)で<strong>必ず自ホスト</strong>に到達します。IPv4ループバックは 127.0.0.0/8 全体が予約されています。 <a href="https://datatracker.ietf.org/doc/html/rfc1122?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">IETF Datatracker+1</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">OAuth 2.0(PKCE)を“正しく”通す</h2>
<h3 class="wp-block-heading">プロトコルの流れ</h3>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">sequenceDiagram
autonumber
participant App as 自動投稿アプリ
participant Browser as ブラウザ
participant XAuth as X 認可サーバ
participant Local as 127.0.0.1:8000
App->>App: code_verifier生成(長さ43〜128 / RFC7636)
App->>App: code_challenge=S256(code_verifier)
App->>Browser: https://x.com/i/oauth2/authorize?...&code_challenge=...
Browser->>XAuth: 認証+同意(tweet.write, users.read, offline.access)
XAuth-->>Local: 302リダイレクト(?code=...&state=...)
Local->>App: 認可コードを渡す
App->>XAuth: POST /2/oauth2/token(code, code_verifier)
XAuth-->>App: access_token(短命), refresh_token
App->>XAuth: POST /2/oauth2/token(refresh_token)※期限切れ時
XAuth-->>App: 新しいaccess_token
</pre></div>
<ul class="wp-block-list">
<li><strong>PKCEの要件</strong>:<code>code_verifier</code> は43〜128文字、<code>code_challenge</code> は通常 <code>S256</code> 推奨。 <a href="https://datatracker.ietf.org/doc/html/rfc7636?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">IETF Datatracker</a></li>
<li><strong>エンドポイント</strong>:認可 <code>x.com/i/oauth2/authorize</code>、トークン <code>api.x.com/2/oauth2/token</code>。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong>TLS 1.2必須</strong>。 <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong>トークン寿命</strong>:<code>expires_in</code> を確認。<code>offline.access</code> で<strong>リフレッシュトークン</strong>を取得し、無人更新するのが定石。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">OAuth 2.0 Simplified</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">最小Python実装(PKCE+ローカルコールバック)</h2>
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>依存:<code>requests</code>、<code>urllib3</code>(標準<code>http.server</code>使用)。<br><strong>App設定</strong>:OAuth2を有効化し、スコープ<code>tweet.write users.read offline.access</code>、<strong>Callbackに <code>http://127.0.0.1:8000/callback</code> を登録</strong>。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></p>
</blockquote>
<pre class="EnlighterJSRAW" data-enlighter-language="python" data-enlighter-theme="" data-enlighter-highlight="" data-enlighter-linenumbers="" data-enlighter-lineoffset="" data-enlighter-title="" data-enlighter-group=""># x_oauth_pkce_min.py
import base64, hashlib, os, secrets, json, threading, webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse as up
import requests
from pathlib import Path
from time import time
CLIENT_ID = os.getenv("X_CLIENT_ID") # developer portalのClient ID
REDIRECT_URI = "http://127.0.0.1:8000/callback"
SCOPES = "tweet.read users.read tweet.write offline.access"
AUTH_URL = "https://x.com/i/oauth2/authorize"
TOKEN_URL = "https://api.x.com/2/oauth2/token"
TOK_PATH = Path("x_tokens.json")
def b64urle(b: bytes)->str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def new_pkce():
verifier = b64urle(secrets.token_bytes(64)) # 86文字程度、RFC7636要件内
challenge = b64urle(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
class CodeHandler(BaseHTTPRequestHandler):
server_version = "MiniAuth/1.0"
code = state = None
def do_GET(self):
q = up.urlparse(self.path)
if q.path != "/callback":
self.send_response(404); self.end_headers(); return
params = up.parse_qs(q.query)
CodeHandler.code = params.get("code", [None])[0]
CodeHandler.state = params.get("state", [None])[0]
self.send_response(200); self.end_headers()
self.wfile.write(b"OK. You can close this tab.")
def log_message(self, *_): pass
def run_local_server():
httpd = HTTPServer(("127.0.0.1", 8000), CodeHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
def save_tokens(data):
data["obtained_at"] = int(time())
TOK_PATH.write_text(json.dumps(data, indent=2))
def load_tokens():
if TOK_PATH.exists():
return json.loads(TOK_PATH.read_text())
return None
def token_expired(t):
# expires_in(秒)を見て、余裕をもって更新
return not t or (time() - t.get("obtained_at", 0)) > (t.get("expires_in", 0) - 60)
def exchange_code(code, verifier):
d = {
"grant_type":"authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code": code,
"code_verifier": verifier,
}
r = requests.post(TOKEN_URL, data=d, timeout=20)
r.raise_for_status()
return r.json()
def refresh(refresh_token):
d = {
"grant_type":"refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
}
r = requests.post(TOKEN_URL, data=d, timeout=20)
r.raise_for_status()
return r.json()
def ensure_token():
t = load_tokens()
if t and not token_expired(t):
return t["access_token"]
if t and "refresh_token" in t:
nt = refresh(t["refresh_token"])
save_tokens(nt)
return nt["access_token"]
verifier, challenge = new_pkce()
state = b64urle(secrets.token_bytes(16))
params = {
"response_type":"code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method":"S256",
}
url = AUTH_URL + "?" + up.urlencode(params, quote_via=up.quote)
httpd = run_local_server()
webbrowser.open(url)
# wait until handler sets code
import time as _t
for _ in range(600):
if CodeHandler.code:
break
_t.sleep(0.2)
httpd.shutdown()
if not CodeHandler.code:
raise RuntimeError("No code received")
tok = exchange_code(CodeHandler.code, verifier)
save_tokens(tok)
return tok["access_token"]
if __name__ == "__main__":
print("Access token:", ensure_token()[:16] + "...")
</pre>
<ul class="wp-block-list">
<li>認可URL/トークンURL/スコープ仕様は<strong>公式のまま</strong>。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform+1</a></li>
<li><code>expires_in</code> を見て<strong>先行リフレッシュ</strong>。<code>offline.access</code> がなければリフレッシュトークンは出ません。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">実際に投稿する(テキストのみ)</h2>
<pre class="EnlighterJSRAW" data-enlighter-language="python" data-enlighter-theme="" data-enlighter-highlight="" data-enlighter-linenumbers="" data-enlighter-lineoffset="" data-enlighter-title="" data-enlighter-group=""># post_text.py
import requests, json
from x_oauth_pkce_min import ensure_token
API_POST = "https://api.x.com/2/tweets"
def post_text(text: str):
tok = ensure_token()
headers = {"Authorization": f"Bearer {tok}", "Content-Type":"application/json"}
body = {"text": text}
r = requests.post(API_POST, headers=headers, data=json.dumps(body), timeout=20)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
print(post_text("Hello from API v2!"))
</pre>
<ul class="wp-block-list">
<li>エンドポイント:<code>POST /2/tweets</code>。レスポンス201が成功。 <a href="https://docs.x.com/x-api/posts/create-post" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>要スコープ:<code>tweet.write</code>(+<code>users.read</code>/<code>tweet.read</code>)。 <a href="https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">画像・動画を添付する</h2>
<p>現行のv2では <strong><code>/2/media/upload</code> のチャンクアップロード(INIT→APPEND→FINALIZE)</strong> が利用可能。アップロード後の <strong><code>media_id</code> を <code>POST /2/tweets</code> に添付</strong> します。 <a href="https://docs.x.com/x-api/media/quickstart/media-upload-chunked?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></p>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">sequenceDiagram
participant App
participant Media as /2/media/upload
App->>Media: INIT (total_bytes, media_type)
loop each chunk
App->>Media: APPEND (segment_index, chunk)
end
App->>Media: FINALIZE
Media-->>App: media_id
App->>/2/tweets: media_ids: [media_id]</pre></div>
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>注意:一部の古い統合ガイドは「v2でフルアップロード不可」と書いていますが、<strong>現在はv2のメディアアップロードが案内されています</strong>(最新ドキュメント参照)。 <a href="https://docs.x.com/x-api/posts/manage-tweets/integrate?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform+1</a></p>
</blockquote>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">失敗しないApp設定の要点(エビデンス付きチェックリスト)</h2>
<ul class="wp-block-list">
<li><strong>Callback URLs</strong>
<ul class="wp-block-list">
<li>App設定の「Callback URL許可リスト」に <strong><code>http://127.0.0.1:8000/callback</code></strong> を登録。</li>
<li>認可リクエストの <code>redirect_uri</code> と<strong>完全一致</strong>させる。</li>
<li><strong><code>localhost</code> は使わない</strong>。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://developer.x.com/ja/docs/basics/apps/guides/callback-urls?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">Developer X</a></li>
</ul>
</li>
<li><strong>OAuth 2.0(PKCE)を選択</strong>し、スコープに <code>tweet.write users.read</code>(必要に応じて <code>tweet.read</code>)+無人運用なら <code>offline.access</code>。 <a href="https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping" target="_blank" rel="noreferrer noopener">X Developer Platform+1</a></li>
<li><strong>TLS 1.2必須</strong>(クライアントのルートストア更新も)。 <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">ネットワーク/プロトコル:SSHローカルフォワードで“Windows → Linux(Raspberry Pi)”を繋ぐ</h2>
<p>「Windowsでブラウザ認証 → 127.0.0.1:8000に返ってくる → そのポートを<strong>Pi上のPython</strong>に転送したい」ケースを想定。</p>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">graph LR
B[Windows ブラウザ] -- https://x.com/i/oauth2/authorize --> X[x.com]
X -- 302 --> CB[Windows 127.0.0.1:8000]
subgraph SSH Tunnel
CB ==ssh -L 8000:127.0.0.1:8000==> PiL[Pi 127.0.0.1:8000]
end
PiL --> AppPi[Pi: ローカルHTTPサーバ&投稿スクリプト]</pre></div>
<ul class="wp-block-list">
<li><strong><code>ssh -L [local_addr:]LPORT:DEST:DESTPORT HOST</code></strong> は「クライアント側ポート→(暗号化)→サーバ側の任意ホスト:ポートへ中継」。
<ul class="wp-block-list">
<li>例(Windows PowerShell): <code>ssh -N -L 127.0.0.1:8000:127.0.0.1:8000 pi@raspi.local</code></li>
<li><code>-N</code> はリモートコマンド実行なしでトンネル専用。OpenSSHのローカルフォワーディング仕様。 <a href="https://man.openbsd.org/OpenBSD-3.7/ssh_config?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">man.openbsd.org</a></li>
</ul>
</li>
<li>サーバ側(Pi)の <code>sshd_config</code> で <strong><code>AllowTcpForwarding yes</code></strong> が必要なことがある。 <a href="https://man.openbsd.org/sshd_config?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">man.openbsd.org</a><a href="https://man7.org/linux/man-pages/man5/sshd_config.5.html?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">man7.org</a></li>
<li>ループバックの性質(127.0.0.0/8は転送されず<strong>必ず自機内で完結</strong>)を理解しておくとデバッグが速い。 <a href="https://datatracker.ietf.org/doc/html/rfc1122?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">IETF Datatracker</a></li>
</ul>
<p><strong>補足(Windowsの常駐化)</strong></p>
<ul class="wp-block-list">
<li>ずっと張りたいなら<strong>タスクスケジューラ</strong>で <code>ssh -N -L ...</code> をログオン時起動に。切断時の再接続は <code>-o ServerAliveInterval=30 -o ServerAliveCountMax=3</code> を併用。</li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">文字数制限・スレッド投稿・重複防止</h2>
<ul class="wp-block-list">
<li>Xの投稿本文の扱いは v2の「数え方」ドキュメントを参照(絵文字、URL短縮など)。 <a href="https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong>重複防止</strong>は、本文のハッシュ+時刻で<strong>冪等性キー</strong>を自前管理(例:直近N件のsha256をKVに保持)。429/503などの再試行は<strong>指数バックオフ</strong>で。</li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">エラーの読み方(実例)</h2>
<ul class="wp-block-list">
<li><strong><code>invalid_request</code>(OAuth2)</strong>:<code>redirect_uri</code> 不一致など。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong><code>Callback URL not approved...</code></strong>:Appの許可リストに未登録。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong>401/403(投稿時)</strong>:スコープ不足・失効トークン。<code>expires_in</code> を見て更新、スコープを追加して<strong>ユーザーに再同意</strong>。 <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">OAuth 2.0 Simplified</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">料金・上限の現実的メモ</h2>
<p><strong>月間Post上限(“Post cap”)はプランで異なります。</strong> 無人運用では上限到達時の挙動(停止 or 翌月まで延期)を設計しましょう。最新のプランと制限は<strong>公式のプラン/Post capページ</strong>で必ず確認してください。 <a href="https://docs.x.com/x-api?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform+1</a></p>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">さらなるハードニング(運用Tips)</h2>
<ul class="wp-block-list">
<li><strong>トークン保管</strong>:平文JSONではなく、OSの秘匿ストアやKMSを使用。Bearerは<strong>流出=即不正利用</strong>(RFC6750)。 <a href="https://datatracker.ietf.org/doc/html/rfc6750?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">IETF Datatracker</a></li>
<li><strong>HTTP→TLS</strong>:コールバック受け取りはローカルでも、外向き通信は当然TLS(v1.2+)。 <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li><strong>ログ</strong>:<code>state</code> の突合、<code>x-access-level</code>、HTTPステータスとレスポンス本文をサニタイズして保存。 <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">参考:メディア添付の最小コード(抜粋)</h2>
<pre class="EnlighterJSRAW" data-enlighter-language="python" data-enlighter-theme="" data-enlighter-highlight="" data-enlighter-linenumbers="" data-enlighter-lineoffset="" data-enlighter-title="" data-enlighter-group=""># media_upload_min.py
import requests, json, math
from x_oauth_pkce_min import ensure_token
MEDIA_URL = "https://api.x.com/2/media/upload"
POST_URL = "https://api.x.com/2/tweets"
def upload_media(path, media_type="image/png", chunk=5*1024*1024):
tok = ensure_token()
h = {"Authorization": f"Bearer {tok}"}
size = os.path.getsize(path)
# INIT
r = requests.post(MEDIA_URL, headers=h, data={"command":"INIT","total_bytes":size,"media_type":media_type})
r.raise_for_status()
media_id = r.json()["media_id"]
# APPEND
with open(path,"rb") as f:
idx=0
while True:
buf = f.read(chunk)
if not buf: break
files = {"media": buf}
d = {"command":"APPEND","media_id":media_id,"segment_index":idx}
rr = requests.post(MEDIA_URL, headers=h, data=d, files=files)
rr.raise_for_status()
idx+=1
# FINALIZE
r = requests.post(MEDIA_URL, headers=h, data={"command":"FINALIZE","media_id":media_id})
r.raise_for_status()
return media_id
def post_with_media(text, media_path):
mid = upload_media(media_path)
tok = ensure_token()
headers={"Authorization": f"Bearer {tok}", "Content-Type":"application/json"}
body={"text": text, "media":{"media_ids":[mid]}}
r = requests.post(POST_URL, headers=headers, data=json.dumps(body))
r.raise_for_status()
return r.json()
</pre>
<ul class="wp-block-list">
<li>チャンク型アップロード(INIT/APPEND/FINALIZE)は<strong>公式クイックスタート</strong>に準拠。 <a href="https://docs.x.com/x-api/media/quickstart/media-upload-chunked?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h2 class="wp-block-heading">付録:HTTPリダイレクトの仕組み(1分で理解)</h2>
<div class="wp-block-merpress-mermaidjs diagram-source-mermaid"><pre class="mermaid">sequenceDiagram
participant Browser
participant XAuth
participant App
Browser->>XAuth: GET /i/oauth2/authorize?...
XAuth-->>Browser: 302 Found + Location: http://127.0.0.1:8000/callback?code=...
Browser->>App: GET /callback?code=...&state=...</pre></div>
<ul class="wp-block-list">
<li>ブラウザは<code>Location</code>ヘッダへ移動(<strong>HTTP 302</strong>)。ローカルのHTTPサーバがコードを受領→アプリがトークンと交換。</li>
<li>X APIとのすべてのやり取りは<strong>TLS 1.2</strong>で保護される。 <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
</ul>
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<h3 class="wp-block-heading">公式ドキュメント(抜粋)</h3>
<ul class="wp-block-list">
<li>認可〜トークン(PKCE):<code>/i/oauth2/authorize</code>、<code>/2/oauth2/token</code> <a href="https://docs.x.com/resources/fundamentals/authentication/oauth-2-0/user-access-token?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>スコープとエンドポイント対応表(投稿に必要スコープ):<a href="https://docs.x.com/fundamentals/authentication/guides/v2-authentication-mapping" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>投稿API:<code>POST /2/tweets</code> <a href="https://docs.x.com/x-api/posts/create-post" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>メディアアップロード(v2):<code>/2/media/upload</code>(INIT/APPEND/FINALIZE) <a href="https://docs.x.com/x-api/media/quickstart/media-upload-chunked?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>Callback/localhost禁止・許可リスト・完全一致: <a href="https://docs.x.com/fundamentals/developer-apps?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">X Developer Platform</a><a href="https://developer.x.com/ja/docs/basics/apps/guides/callback-urls?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">Developer X</a></li>
<li>TLS要件(1.2必須): <a href="https://docs.x.com/fundamentals/authentication/guides/tls" target="_blank" rel="noreferrer noopener">X Developer Platform</a></li>
<li>127.0.0.0/8はループバック: <a href="https://datatracker.ietf.org/doc/html/rfc1122?utm_source=chatgpt.com" target="_blank" rel="noreferrer noopener">IETF Datatracker</a></li>
</ul>
この記事は固有情報を隠すため、<PI_HOST>
, <PI_USER>
, https://example.com
などプレースホルダで書いています。コピペ後に自分の値へ置換してください。
Xへの自動投稿を“ちゃんと”作る
― OAuth 2.0 PKCE、127.0.0.1コールバック、トークン管理、メディア添付、SSHトンネルまで
1. 全体図
flowchart LR
subgraph Client["自動投稿クライアント(Python)"]
A[PKCE生成] --> B[認可URL生成]
B -->|ブラウザ起動| C[ユーザログイン]
E[127.0.0.1:8000 ローカルHTTPサーバ] --> F[認可コード受領]
F --> G[トークン交換 /2/oauth2/token]
G --> H[投稿 /2/tweets]
H --> I[結果保存・再試行制御]
end
subgraph X["X Platform"]
XAuth[Auth Server]:::cloud
XAPI[REST API]:::cloud
end
C-->XAuth
E<--XAuth
G-->XAuth
H-->XAPI
classDef cloud fill:#eef,stroke:#99f
なぜ 127.0.0.1 で、localhost はダメなの?
Xの公式ガイダンスに**「localhostはコールバックURLに使わないで。代わりに http(s)://127.0.0.1
を使って」と明記されています。さらにApp設定(許可リスト)と実際に投げる redirect_uri
は完全一致**である必要があります。 X Developer PlatformDeveloper X
ネットワーク的背景:
localhost
はOSの名前解決に依存し、開発環境によっては ::1
(IPv6)へ解決され、ブラウザ↔ローカルサーバ間でアドレス不一致が起こりがち。
127.0.0.1
はIPv4のループバック(OSカーネル内で折り返す)で必ず自ホストに到達します。IPv4ループバックは 127.0.0.0/8 全体が予約されています。 IETF Datatracker+1
OAuth 2.0(PKCE)を“正しく”通す
プロトコルの流れ
sequenceDiagram
autonumber
participant App as 自動投稿アプリ
participant Browser as ブラウザ
participant XAuth as X 認可サーバ
participant Local as 127.0.0.1:8000
App->>App: code_verifier生成(長さ43〜128 / RFC7636)
App->>App: code_challenge=S256(code_verifier)
App->>Browser: https://x.com/i/oauth2/authorize?...&code_challenge=...
Browser->>XAuth: 認証+同意(tweet.write, users.read, offline.access)
XAuth-->>Local: 302リダイレクト(?code=...&state=...)
Local->>App: 認可コードを渡す
App->>XAuth: POST /2/oauth2/token(code, code_verifier)
XAuth-->>App: access_token(短命), refresh_token
App->>XAuth: POST /2/oauth2/token(refresh_token)※期限切れ時
XAuth-->>App: 新しいaccess_token
最小Python実装(PKCE+ローカルコールバック)
依存:requests
、urllib3
(標準http.server
使用)。
App設定:OAuth2を有効化し、スコープtweet.write users.read offline.access
、Callbackに http://127.0.0.1:8000/callback
を登録。 X Developer Platform
# x_oauth_pkce_min.py
import base64, hashlib, os, secrets, json, threading, webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
import urllib.parse as up
import requests
from pathlib import Path
from time import time
CLIENT_ID = os.getenv("X_CLIENT_ID") # developer portalのClient ID
REDIRECT_URI = "http://127.0.0.1:8000/callback"
SCOPES = "tweet.read users.read tweet.write offline.access"
AUTH_URL = "https://x.com/i/oauth2/authorize"
TOKEN_URL = "https://api.x.com/2/oauth2/token"
TOK_PATH = Path("x_tokens.json")
def b64urle(b: bytes)->str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def new_pkce():
verifier = b64urle(secrets.token_bytes(64)) # 86文字程度、RFC7636要件内
challenge = b64urle(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
class CodeHandler(BaseHTTPRequestHandler):
server_version = "MiniAuth/1.0"
code = state = None
def do_GET(self):
q = up.urlparse(self.path)
if q.path != "/callback":
self.send_response(404); self.end_headers(); return
params = up.parse_qs(q.query)
CodeHandler.code = params.get("code", [None])[0]
CodeHandler.state = params.get("state", [None])[0]
self.send_response(200); self.end_headers()
self.wfile.write(b"OK. You can close this tab.")
def log_message(self, *_): pass
def run_local_server():
httpd = HTTPServer(("127.0.0.1", 8000), CodeHandler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
def save_tokens(data):
data["obtained_at"] = int(time())
TOK_PATH.write_text(json.dumps(data, indent=2))
def load_tokens():
if TOK_PATH.exists():
return json.loads(TOK_PATH.read_text())
return None
def token_expired(t):
# expires_in(秒)を見て、余裕をもって更新
return not t or (time() - t.get("obtained_at", 0)) > (t.get("expires_in", 0) - 60)
def exchange_code(code, verifier):
d = {
"grant_type":"authorization_code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code": code,
"code_verifier": verifier,
}
r = requests.post(TOKEN_URL, data=d, timeout=20)
r.raise_for_status()
return r.json()
def refresh(refresh_token):
d = {
"grant_type":"refresh_token",
"client_id": CLIENT_ID,
"refresh_token": refresh_token,
}
r = requests.post(TOKEN_URL, data=d, timeout=20)
r.raise_for_status()
return r.json()
def ensure_token():
t = load_tokens()
if t and not token_expired(t):
return t["access_token"]
if t and "refresh_token" in t:
nt = refresh(t["refresh_token"])
save_tokens(nt)
return nt["access_token"]
verifier, challenge = new_pkce()
state = b64urle(secrets.token_bytes(16))
params = {
"response_type":"code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPES,
"state": state,
"code_challenge": challenge,
"code_challenge_method":"S256",
}
url = AUTH_URL + "?" + up.urlencode(params, quote_via=up.quote)
httpd = run_local_server()
webbrowser.open(url)
# wait until handler sets code
import time as _t
for _ in range(600):
if CodeHandler.code:
break
_t.sleep(0.2)
httpd.shutdown()
if not CodeHandler.code:
raise RuntimeError("No code received")
tok = exchange_code(CodeHandler.code, verifier)
save_tokens(tok)
return tok["access_token"]
if __name__ == "__main__":
print("Access token:", ensure_token()[:16] + "...")
実際に投稿する(テキストのみ)
# post_text.py
import requests, json
from x_oauth_pkce_min import ensure_token
API_POST = "https://api.x.com/2/tweets"
def post_text(text: str):
tok = ensure_token()
headers = {"Authorization": f"Bearer {tok}", "Content-Type":"application/json"}
body = {"text": text}
r = requests.post(API_POST, headers=headers, data=json.dumps(body), timeout=20)
r.raise_for_status()
return r.json()
if __name__ == "__main__":
print(post_text("Hello from API v2!"))
画像・動画を添付する
現行のv2では /2/media/upload
のチャンクアップロード(INIT→APPEND→FINALIZE) が利用可能。アップロード後の media_id
を POST /2/tweets
に添付 します。 X Developer Platform
sequenceDiagram
participant App
participant Media as /2/media/upload
App->>Media: INIT (total_bytes, media_type)
loop each chunk
App->>Media: APPEND (segment_index, chunk)
end
App->>Media: FINALIZE
Media-->>App: media_id
App->>/2/tweets: media_ids: [media_id]
注意:一部の古い統合ガイドは「v2でフルアップロード不可」と書いていますが、現在はv2のメディアアップロードが案内されています(最新ドキュメント参照)。 X Developer Platform+1
失敗しないApp設定の要点(エビデンス付きチェックリスト)
ネットワーク/プロトコル:SSHローカルフォワードで“Windows → Linux(Raspberry Pi)”を繋ぐ
「Windowsでブラウザ認証 → 127.0.0.1:8000に返ってくる → そのポートをPi上のPythonに転送したい」ケースを想定。
graph LR
B[Windows ブラウザ] -- https://x.com/i/oauth2/authorize --> X[x.com]
X -- 302 --> CB[Windows 127.0.0.1:8000]
subgraph SSH Tunnel
CB ==ssh -L 8000:127.0.0.1:8000==> PiL[Pi 127.0.0.1:8000]
end
PiL --> AppPi[Pi: ローカルHTTPサーバ&投稿スクリプト]
ssh -L [local_addr:]LPORT:DEST:DESTPORT HOST
は「クライアント側ポート→(暗号化)→サーバ側の任意ホスト:ポートへ中継」。
- 例(Windows PowerShell):
ssh -N -L 127.0.0.1:8000:127.0.0.1:8000 pi@raspi.local
-N
はリモートコマンド実行なしでトンネル専用。OpenSSHのローカルフォワーディング仕様。 man.openbsd.org
- サーバ側(Pi)の
sshd_config
で AllowTcpForwarding yes
が必要なことがある。 man.openbsd.orgman7.org
- ループバックの性質(127.0.0.0/8は転送されず必ず自機内で完結)を理解しておくとデバッグが速い。 IETF Datatracker
補足(Windowsの常駐化)
- ずっと張りたいならタスクスケジューラで
ssh -N -L ...
をログオン時起動に。切断時の再接続は -o ServerAliveInterval=30 -o ServerAliveCountMax=3
を併用。
文字数制限・スレッド投稿・重複防止
- Xの投稿本文の扱いは v2の「数え方」ドキュメントを参照(絵文字、URL短縮など)。 X Developer Platform
- 重複防止は、本文のハッシュ+時刻で冪等性キーを自前管理(例:直近N件のsha256をKVに保持)。429/503などの再試行は指数バックオフで。
エラーの読み方(実例)
料金・上限の現実的メモ
月間Post上限(“Post cap”)はプランで異なります。 無人運用では上限到達時の挙動(停止 or 翌月まで延期)を設計しましょう。最新のプランと制限は公式のプラン/Post capページで必ず確認してください。 X Developer Platform+1
さらなるハードニング(運用Tips)
参考:メディア添付の最小コード(抜粋)
# media_upload_min.py
import requests, json, math
from x_oauth_pkce_min import ensure_token
MEDIA_URL = "https://api.x.com/2/media/upload"
POST_URL = "https://api.x.com/2/tweets"
def upload_media(path, media_type="image/png", chunk=5*1024*1024):
tok = ensure_token()
h = {"Authorization": f"Bearer {tok}"}
size = os.path.getsize(path)
# INIT
r = requests.post(MEDIA_URL, headers=h, data={"command":"INIT","total_bytes":size,"media_type":media_type})
r.raise_for_status()
media_id = r.json()["media_id"]
# APPEND
with open(path,"rb") as f:
idx=0
while True:
buf = f.read(chunk)
if not buf: break
files = {"media": buf}
d = {"command":"APPEND","media_id":media_id,"segment_index":idx}
rr = requests.post(MEDIA_URL, headers=h, data=d, files=files)
rr.raise_for_status()
idx+=1
# FINALIZE
r = requests.post(MEDIA_URL, headers=h, data={"command":"FINALIZE","media_id":media_id})
r.raise_for_status()
return media_id
def post_with_media(text, media_path):
mid = upload_media(media_path)
tok = ensure_token()
headers={"Authorization": f"Bearer {tok}", "Content-Type":"application/json"}
body={"text": text, "media":{"media_ids":[mid]}}
r = requests.post(POST_URL, headers=headers, data=json.dumps(body))
r.raise_for_status()
return r.json()
付録:HTTPリダイレクトの仕組み(1分で理解)
sequenceDiagram
participant Browser
participant XAuth
participant App
Browser->>XAuth: GET /i/oauth2/authorize?...
XAuth-->>Browser: 302 Found + Location: http://127.0.0.1:8000/callback?code=...
Browser->>App: GET /callback?code=...&state=...
- ブラウザは
Location
ヘッダへ移動(HTTP 302)。ローカルのHTTPサーバがコードを受領→アプリがトークンと交換。
- X APIとのすべてのやり取りはTLS 1.2で保護される。 X Developer Platform
公式ドキュメント(抜粋)
コメント