はじめに
X(旧Twitter)への画像付き自動投稿をGAS(Google Apps Script)で実装したところ、テキスト投稿は成功しているのに画像アップロードだけが401エラーで落ちる現象に遭遇しました。
ログ上は「動いているように見える」のに、SNSに表示される投稿には画像が消えている。これが厄介で、しばらく気付けませんでした。
原因はOAuth 1.0aの署名処理と画像サイズ上限の2点。本記事ではハマりポイントと修正コードを共有します。
環境
| 項目 | バージョン・設定 |
|---|---|
| 実行環境 | Google Apps Script (V8 Runtime) |
| 投稿テキスト | X API v2 (POST /2/tweets) |
| 画像アップロード | X API v1.1 (POST /1.1/media/upload.json) |
| 認証方式 | OAuth 1.0a (User Context) |
| 署名アルゴリズム | HMAC-SHA1 |
| 画像形式 | PNG / JPEG |
症状
[INFO] tweet_text posted: success (id=1234567890)
[ERROR] media/upload: 401 {"errors":[{"code":32,"message":"Could not authenticate you."}]}
-
GET /2/users/me… 200 OK(認証は通る) -
POST /2/tweets(テキストのみ) … 200 OK -
POST /1.1/media/upload.json… 401 Could not authenticate you
「v2は通るのにv1.1だけ落ちる」という非対称性が手がかりになりました。
原因1: OAuth 1.0a署名にPOSTパラメータを含めていなかった
v1.1 と v2 の決定的な違い
| 項目 | v2 (/2/tweets) |
v1.1 (/1.1/media/upload.json) |
|---|---|---|
| Content-Type | application/json |
application/x-www-form-urlencoded または multipart/form-data
|
| 署名対象 | OAuthパラメータのみ | OAuthパラメータ + POSTボディのフィールド |
GASで UrlFetchApp.fetch(url, { method: 'post', payload: { ... } }) のようにpayloadをオブジェクトで渡すと、自動的にapplication/x-www-form-urlencodedに変換されます。このときPOSTフィールドも署名のbase stringに含めなければ署名が一致しません。
v2はJSONなのでこの考慮が不要だったため、同じ署名関数を使い回していると気付かないうちに踏みます。
修正コード
/**
* OAuth 1.0a 署名生成
* @param {string} method - 'POST' / 'GET'
* @param {string} url - クエリ・パラメータを除いたURL
* @param {Object} oauthParams - oauth_consumer_key 等のOAuthパラメータ
* @param {Object} bodyParams - POSTボディ(form-encoded時のみ。JSON時は {} を渡す)
* @param {string} consumerSecret
* @param {string} tokenSecret
*/
function buildOAuthSignature(method, url, oauthParams, bodyParams, consumerSecret, tokenSecret) {
// 署名ベース文字列に OAuth + POSTフィールド を全部含める
const allParams = { ...oauthParams, ...bodyParams };
const sortedKeys = Object.keys(allParams).sort();
const paramString = sortedKeys
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(allParams[k])}`)
.join('&');
const baseString = [
method.toUpperCase(),
encodeURIComponent(url),
encodeURIComponent(paramString),
].join('&');
const signingKey = `${encodeURIComponent(consumerSecret)}&${encodeURIComponent(tokenSecret)}`;
// 必ず HMAC_SHA_1。SHA256 を使うと oauth_signature_method=HMAC-SHA1 と矛盾する
const sigBytes = Utilities.computeHmacSignature(
Utilities.MacAlgorithm.HMAC_SHA_1,
baseString,
signingKey
);
return Utilities.base64Encode(sigBytes);
}
ポイントは、oauth_signature_method で宣言したアルゴリズムと、実際に呼ぶ computeHmacSignature のMacAlgorithmが一致しているか。GASには computeHmacSignature(SHA1デフォルト)と computeHmacSha256Signature があり、前者を使ったうえで MacAlgorithm.HMAC_SHA_1 を明示するのが確実です。
原因2: PNGの画像サイズが5MB上限を超えていた
OAuth問題を直したあとも、特定の投稿だけ401が継続。画像をローカルで確認するとPNGスクリーンショットが7MBありました。
X API の制限は以下の通り。
| メディア | 上限 |
|---|---|
| 画像 (PNG/JPEG/WebP) | 5 MB |
| アニメーションGIF | 15 MB |
| 動画 | 512 MB |
PNGは透過情報を保持するためスクリーンショットだと簡単に5MBを超えます。投稿フローに自動圧縮を組み込みました。
/**
* X API用に画像を5MB以下に圧縮する
* - PNG → JPEG変換(透過は白背景で潰す)
* - 段階的に画質を下げて再エンコード
*/
function compressImageForX(blob) {
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
// すでに5MB以下ならそのまま
if (blob.getBytes().length <= MAX_SIZE) return blob;
// PNG → JPEG(GASのImage処理はApp Script側では限定的なので、
// 実装ではDriveApp + Picasaの代わりに、外部の画像変換サービスや
// Cloud Functions側で処理する選択肢もある)
let current = blob;
let quality = 85;
while (quality >= 50) {
// 例: GAS外部APIや独自Cloud Functionで再エンコード
current = recompressJpeg(current, quality);
if (current.getBytes().length <= MAX_SIZE) return current;
quality -= 10;
}
throw new Error('画像を5MB以下に圧縮できませんでした');
}
GAS単体では画像エンコーダが弱いため、運用では Cloud Run / Cloud Functions に圧縮を切り出すか、画像生成側で JPEG・85% 品質を初期値にすることをおすすめします。
原因3(設計上の落とし穴): エラーを握りつぶす投稿フロー
最初のコードは「画像アップロードが失敗してもテキストだけで投稿を続行」する仕様でした。これはSNS運用としては最悪です。
- ❌ 画像が消えていることに気付くのは数日後(SNS表示を見てから)
- ❌ ログには「投稿成功」と残るので調査が遅れる
修正後のフロー
function postTweetWithImage(text, imageBlob) {
let mediaId;
try {
const compressed = compressImageForX(imageBlob);
mediaId = uploadMedia_v1_1(compressed); // 失敗時は throw
} catch (e) {
Logger.log(`[ABORT] media upload failed: ${e.message}`);
notifyDiscord(`画像アップロード失敗のため投稿を中止: ${e.message}`);
throw e; // ← 投稿自体を中止
}
return postTweet_v2(text, [mediaId]);
}
「画像なしでもよし」とするか「画像なしなら投稿しない」とするかは事業要件次第ですが、少なくとも握りつぶしてはいけない。失敗時に必ずDiscord等に通知する設計にしました。
まとめ:v1.1 media/upload で401になった時のチェックリスト
| # | チェック項目 | 確認方法 |
|---|---|---|
| 1 | OAuth署名base stringにPOSTフィールドを含めているか | base stringをログ出力して目視 |
| 2 |
oauth_signature_method と実際の HMAC アルゴリズムが一致しているか |
MacAlgorithm.HMAC_SHA_1 を明示 |
| 3 | 画像サイズが5MB以下か |
blob.getBytes().length をログ |
| 4 | OAuth callbackや権限スコープがRead and Writeか |
Developer Portalで確認 |
| 5 | システム時計が大きくズレていないか |
oauth_timestamp の検証 |
自動化システムは「動いている」ように見えて壊れる
今回の事象で改めて感じたのは、自動化の信頼性は「動いている」ことではなく「壊れていることを検知できる」ことで決まるという点です。
- ✅ 各APIコールごとに成功/失敗をDiscord通知
- ✅ 画像付き投稿は「画像が無いなら投稿しない」を原則に
- ✅ 月1回の手動レビューで「期待値と実際のSNS表示」を突合
OAuth 1.0aは古い仕様ですが、X API以外でも生き残っています。同じ罠にハマる方の助けになれば幸いです。
#XAPI #GAS自動化 #OAuth認証
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中