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?

GAS × X API:v1.1 media/uploadで401エラーになる原因と、画像7MB問題を含めた完全対処

0
Posted at

はじめに

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.json401 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) — 日々の知見を発信中

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?