はじめに
「Instagramの投稿URLをGoogleフォームに貼ったら、自動的にGoogleドライブへ保存される仕組みが欲しい」 そう思い立ってバイブコーディングで作り始めたました!
Gemini 3 無料枠
この記事では、私が開発中に躓いた「GASのトリガー問題」や「APIレスポンスの構造解析」など、試行錯誤の記録をまとめました。
開発環境
- Google Apps Script (GAS)
- Google フォーム
- RapidAPI (instagram120)
- Google ドライブ
躓きポイント1:トリガー設定に「フォームから」が出てこない
最初の壁は、フォーム送信時にスクリプトを動かすための設定でした。 Geminiは「イベントのソースを選択:フォームから」を選ぶと言ってるのに、私の画面には「スプレッドシートから」しか出てこない……。
原因と解決
これは、GASの作り方に原因がありました。
- NG: Googleドライブの「新規」から直接GASファイルを作った(スタンドアロン・スクリプト)。
- OK: Googleフォームの編集画面にある「︙(三点リーダー)」→「スクリプト エディタ」 から作成する(コンテナバインド・スクリプト)
GASは「どこから開いたか」で持っている権限や設定項目が変わるんですね。この違いを知っているだけで、躓きを防げます。
躓きポイント2:Cannot read properties of undefined (reading 'getResponse')
いざ実行!と思ったら、実行ログにこのエラー。 これは、プログラムが「2番目の回答(保存先など)」を取得しようとしているのに、実際のフォームには「URL」の質問1つしかなかったために発生していました。
実は最初は保存先をフォームで選択できるように2つ目にその質問を追加する予定でコーディングしてました。
// エラーになった箇所のイメージ
const itemResponses = e.response.getItemResponses();
const fullUrl = itemResponses[0].getResponse();
const destination = itemResponses[1].getResponse(); // 2つ目の質問がないため、ここで undefined エラー!
将来的に項目を増やす予定でも、現時点でのフォームの質問数と、コード内のインデックス([0], [1])は厳密に合わせる必要があることを学びました。
躓きポイント3:APIのレスポンスが「配列」だった
今回使用したAPIはinstagram120 というRapidAPIにあるものを使用しました。
https://rapidapi.com/3205/api/instagram120
投稿データを取得すると、画像URLが1つだけ返ってくると思いきや、実際には 「複数枚投稿」に対応したリスト形式でデータが返ってきていました。
ログを確認するとこんな構造でした:
[ { "pictureUrl": "...", ... }, { "pictureUrl": "...", ... } ]
この構造を理解せずに単一のオブジェクトとして扱おうとしても、画像は見つかりません。forEach を使って、リストの中身を一つずつ取り出す処理に書き換えることで、複数枚投稿の全保存に対応できました。
実装のこだわりポイント
1. 日本時間(JST)への確実な変換
APIから返ってくる投稿日時はUTC(世界標準時)ですが、管理しやすいように日本時間に変換しました。 Utilities.formatDate(date, "JST", "yyyyMMdd_HHmm") を使うことで、夜中に投稿されたものも正しく「日本時間」でファイル名に反映されます。
2. 「上書き保存」ロジックの導入
同じURLを2回送ったとき、同じファイルが重複するのを防ぐため、「保存先に同名のファイルがあれば、古い方をゴミ箱へ移動してから新しく保存する」 という処理を追加しました。 これにより、1回目が失敗した時のリトライもスムーズになり、フォルダが散らかるのも防げます。
3. 拡張子 .jpg の明示
Googleドライブ上では拡張子がなくてもプレビューできますが、PCにダウンロードして整理したり、今後別のサービスと連携する際の利便性を考えて、あえて .jpg を付加する仕様にしました。
完成したコード (メインロジック)
/**
* フォーム送信時に実行されるメイン関数
*/
function onFormSubmit(e) {
const props = PropertiesService.getScriptProperties().getProperties();
const itemResponses = e.response.getItemResponses();
const fullUrl = itemResponses[0].getResponse().trim();
// 1. ショートコードの抽出
const shortcode = fullUrl.match(//(?:p|reels|reel)/([^/?#&]+)/)[1];
// 2. APIリクエスト
const apiUrl = '[https://instagram120.p.rapidapi.com/api/instagram/mediaByShortcode](https://instagram120.p.rapidapi.com/api/instagram/mediaByShortcode)';
const options = {
"method": "post",
"contentType": "application/json",
"headers": {
"x-rapidapi-key": props.RAPID_API_KEY,
"x-rapidapi-host": "instagram120.p.rapidapi.com"
},
"payload": JSON.stringify({ "shortcode": shortcode })
};
const response = UrlFetchApp.fetch(apiUrl, options);
const results = JSON.parse(response.getContentText()); // ここが配列 []
const folder = DriveApp.getFolderById(props.DRIVE_FOLDER_ID);
// 3. 取得した全画像をループ処理で保存
results.forEach((item, index) => {
const imgUrl = item.pictureUrl;
if (!imgUrl) return;
const username = item.meta?.username || "unknown";
const date = new Date(item.meta?.takenAt * 1000);
const dateString = Utilities.formatDate(date, "JST", "yyyyMMdd_HHmm");
const fileName = `insta_${username}_${dateString}_${index + 1}.jpg`;
// 上書きロジック(同名ファイルをゴミ箱へ)
const existingFiles = folder.getFilesByName(fileName);
while (existingFiles.hasNext()) {
existingFiles.next().setTrashed(true);
}
const blob = UrlFetchApp.fetch(imgUrl).getBlob();
folder.createFile(blob).setName(fileName);
console.log(`保存完了: ${fileName}`);
});
}
振り返りと今後の展望
今回は「画像」に絞りましたが、APIレスポンスを詳しく解析すると動画URL(videoUrl)も含まれているようなので、それも含め今後は...
- 動画(MP4)保存対応
- エラー通知機能
- APIの無料枠を使い切った際や、通信エラー時にLINEやメールで自分に通知する。
- Googleフォトへの自動同期 or 直保存(Google Driveは画像閲覧しずらいので...)
- インスタアカウントごとにフォルダを自動生成してそこに保存
辺りを検討しましたが気が向いたらまたやりましょう!
一旦は完成ということで!
