宮島でお抹茶をいただく
ブログサイトで利用する「チャットボット」がようやく完成しました!
今開発では,バックエンド(サーバ側)にGoogle Apps
Script(GAS)を採用し,フロントエンド(画面側)にHTML/JavaScriptを用いたWebアプリケーションの仕組みとしています。
LINEやWebhookなどを使った構築も「CORS」エラーを発生させないため魅力的なのですが,当研究会ブログ用ツールとして活用したかったため,今回は採用しませんでした。
GAS×HTML/JavaScriptの構成は廉価で便利ですが,開発中やテストの段階で,いろいろな「課題」に直面します。
特に通信が失敗したり止まったりする現象は,その裏に潜む「302エラー(リダイレクト)」への対応が必要です。
今回は,チャットボット開発を通じて得た「GAS×HTMLのテスト環境の構築方法」と,「302エラーの正体とその具体的な対策」を実体験を交えて記載します。
それでは,学習を開始します。
1.GAS×HTML対応Webアプリのテスト環境の作成
チャット画面起動用のHTML/JavaScriptファイルをパソコンのローカル環境で作成し,ブラウザで開くとアドレスバーは「file:///C:/.../index.html 」のようになります。
ここからGASにデータを送ろうとすると,ブラウザのセキュリティルール(CORS制限)に引っかかり,通信が完全に遮断されてしまいます。
なぜ file:// ではダメなのか?
ブラウザには,異なるオリジン(ドメインやプロトコル)間の通信を制限するCORS(クロスオリジンリソース共有)という厳しいセキュリティルールがあります。
GAS(https://...)から見ると,ローカルファイル(file://...)は「安全性が確認できない不明な接続元」とみなされます。プロトコル(http/https と file)自体が異なるため,ブラウザ側で通信が完全に遮断されてしまいます。
解決策:ローカルサーバ(http://)を立ち上げる
この問題をクリアするためには,ローカル環境であっても file://
ではなく,きちんと http://localhost:...
というWebサーバの形でHTMLを実行する必要があります。
そこで今回は,ローカル側のWSL2(Windows Subsystem for Linux
2)を利用して,内部に簡易なWebサーバを立ち上げることでこの問題をクリアしました。WSL2を使うことで,本番環境に近い挙動でテストを行うことができます。
WSL2構築については過去のブログをご覧ください。
なお,すでにインターネット上に自身のWebサーバ(レンタルサーバなど)を所有している方であれば,「その公開サイト上にテスト用のHTMLファイルを一時的にアップロードしてアクセスする」という方法で,同様にCORS制限を回避したテストが可能です。
これは本番環境に最も近い最適なテスト環境です。
2.「302エラー」の正体とは?
次にHTMLからGASに向けてデータを送信(fetch)した際,ブラウザのデベロッパーツール(F12)のネットワークタブを確認すると,赤字で「302」や「CORSエラー」と表示されて通信が失敗することがあります。
302エラー(Found)の基本的な意味
HTTPステータスコード「302」は,「要求されたデータを発見したので,一時的に別のURLに移動しています(リダイレクト)」という意味です。エラーというよりも「別の場所に転送するよ」という案内なのですが,これがプログラム同士の通信(API通信)で起きると問題になります。
なぜGASで302が発生するのか?
GASをWebアプリとして公開すると「script.google.com」というURLが発行されます。
しかし,実際に外部からデータが送られてくると,Googleのセキュリティ仕様により,内部的に
「script.googleusercontent.com」というまったく別のドメインのURLへ自動的にリダイレクト(転送)される仕組みになっています。
ブラウザのJavaScriptは,セキュリティ上の理由(CORS制限)から,この「勝手な転送(リダイレクト)」を検知すると通信をブロックしてしまいます。これが,GAS×HTML開発で最も難しい「302エラー」の正体です。
【CORS(クロスオリジンリソース共有)制限とは】
問い合わせたサイトと異なるドメイン体系のサイトにアクセスするなど怪しい動きと検知した場合に,ブラウザが通信をブロックする厳格な仕組みです。
3.【即実践】302エラーを回避・解決する3つの対策
この問題を解決するためには,GAS側(受け手)とHTML側(送り手),そして公開設定の3箇所で正しい設定を行う必要があります。
対策①: GAS側:ContentService の正しい返し方
GAS側の doPost(e) や doGet(e)
で処理結果をHTMLに返す際,単にテキストを返すのではなく,明確に「JSON形式」として,かつMIMEタイプを指定して返却します。
GAS script
function doPost(e) {
// チャットボットの処理...
var result = { "status": "success", "message": "返信メッセージ" };
// 正しい返し方:MIMEタイプをJSONに指定する
return ContentService.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
}
return値には「.setMimeType(ContentService.MimeType.JSON)」コードを必ずにつけてください。
対策②: HTML側:fetch のオプション設定
HTML側のJavaScriptからGASへデータを送る際,fetch
関数を使っている時は,リダイレクトをどう処理するかを指示するオプション(redirect: 'follow')を明記します。
これによって,GoogleがURLを転送しても,ブラウザが自動でその転送先まで追いかけてデータを取得してくれます。
「mode」は「no-cors」にする方法もありますが,これはGASへの一方通行になり,Returnを受けることができなくなります。
また,CORSの「プリフライトリクエスト(事前確認通信)」がエラーを起こす主犯になります。
これを回避するために,fetch の中身をブラウザが事前確認を必要としない「単純リクエスト条件」(Content-Type: Text/plain)に設定します。以下は,fetch通信の一例です。
Web javascript
// 3. GASへPOSTリクエストを送信
const params = JSON.stringify({ message:userText, siteurl: window.location.origin, userId: userId });
const options = {method: 'POST', mode: 'cors', // CORS通信を許可
redirect: "follow", //GASの仕様上、必須
headers: { "Content-Type": "text/plain" // ★重要:text/plainで送るのがGASの通則です },
body: params
};
fetch(GAS_URL, options)
.then(response => response.json())
.then(result => { console.log('成功:',result); })
.catch(error => { console.error('エラー:', error); });
対策③: GASのWebアプリ・デプロイ時のアクセス権限設定
チャットボットを一般公開したり,ログインしていないユーザー(または外部システム)からも使えるようにしたりする場合,GASデプロイ時の設定が非常に重要です。
- 新しいデプロイを追加(または編集)を開きます。
- 「次のユーザーとして実行」を「自分(あなたのGoogleアカウント)」にします。
- 「アクセスできるユーザー」を「全員(Anyone)」にします。
ここが設定されていないと,通信した瞬間にGoogleのログイン画面へリダイレクトされてしまい,結果として302エラー(または認証エラー)を引き起こします。
(注)デプロイ初回時は,実行時に必ず認証許可を要求されますので許可してください。
4.テスト実行とエラー解消の確認手順
対策を施したら,最後に正しく動いているか確認します。
- 本番用URLで確認: アクセス権限を「全員」にした場合は,「新しいデプロイ」で発行された本番用URL(/exec)を使ってテスト通信を行います。
- デベロッパーツールの確認: ブラウザでF12キーを押し,「Network(ネットワーク)」タブを開いた状態でチャットボットを動かします。
- Statusを確認: 対象の通信のステータスが「200 OK」になり,GASから返ってきたメッセージ(JSONデータ)がコンソールや画面に表示されていれば,見事302エラーの克服に成功です!
なお,このデプロイについては,デプロイの度に本番URLが変わらないようにするなど,多少の注意点があります。この点については,後述のブログで記述します。
5.【最終決断】スマホブラウザでの不安定動作対策
これで無事にPC上のテストは動くようになったのですが,さらなる問題に直面しました。
「スマホのブラウザ(Chrome)で動かすと,なぜか動作が不安定になる」
という現象です。
スマートフォン環境では,サードパーティ・クッキーの制限やプライバシー保護機能がPCよりも厳しく働きます。
そのため,外部サイト(今回の自作Webサイト)から fetch
を使ってGoogleのサーバへAPI通信を行うと,セッションの維持やリダイレクト処理のどこかでブロックされたり,通信が切断されたりしてしまいます。
これでは,実用的なチャットボットとして使えません。
結論:「fetch」を諦め,「google.script.run」へ変更
試行錯誤の末,今回は外部からのfetch通信による連携を断念しました。さらに深くまで解析すれば解決策が見いだせたかもしれませんが。そのための時間がもったいないと判断したからです。
代わりに,HTML(フロントUI)をGASのプロジェクト内部に完全に内包させ,GASのWebアプリ機能でHTMLを表示することにしました。画面とのデータ通信は,GASのネイティブAPIである
「google.script.run」を実行する仕様へと根本から切り替えます。
「google.script.run」のスクリプト(HTML側JavaScript)例は以下のとおりです。
Web javascript
const payload = JSON.stringify({
message: userText,
siteurl: ALLOWED_ORIGIN,
userId: userId
});
// fetchを使わず,GAS側の関数「仮称:proxyToDoPost」を直接呼び出す
google.script.run
.withSuccessHandler(function(responseString) {
var data = JSON.parse(responseString); // 既存の受信データ
// チャット画面に返答を表示する処理
})
.withFailureHandler(function(error) {
console.error("エラー発生:", error);
})
.proxyToDoPost(payload); // GAS側の中間関数を実行
上記は,payloadというJsonデータの文字列を「proxyToDoPost関数」に渡して,Return値「responseString」をfunction(responseString)関数で処理している例になります。
この方法であれば,フロントエンドとバックエンドが同じ「Googleの同一ドメイン内」で完結するため,302リダイレクトもCORS制限も一切発生しません。スマホのブラウザからアクセスしても,一切もたつくことなく,非常に安定してチャットボットが動作するようになりました。
今回は,これまで作成したdoPost(e)関数を,google.script.runで直接呼び出すことはできないため,doPost(e)を呼び出す関数「仮称:proxyToDoPost関数」を作るようにしました。
doPost(e)を呼び出す関数「仮称:proxyToDoPost関数」の具体的コード例は次のような感じです。GASのコード.gsの一番下に加えます。
doPost中間読込 script
// CORS回避用の中継関数(既存のdoPostへデータを丸投げする)
function proxyToDoPost(payload) {
// HTMLから届いたJSON文字列をオブジェクトに復元
const data = JSON.parse(payload);
// dataの確認処理など
var mockEvent = { postData: { contents: payload } };
var result = doPost(mockEvent); // 既存のdoPostを実行
return result.getContent(); // 結果をそのまま返す
}
doPost(e)からのReturn値をgetContent()で操作することで,オブジェクト内に格納されている
「純粋な文字列データ(JSONやJavaScriptコード)」だけを外に取り出す
ことができます。
この「google.script.run」は,GASと同じドメインで動くため,Web/javascript内でiframeタグを使いGASの内部HTMLを呼び出すことで動作させます。
なお,iframe内ではWeb/javascript(親画面)で動く関数(window,location.origin等)は使えませんので注意してください。
どうしても親画面側の情報が欲しい場合は,iframe の src
属性(GASのデプロイURL)の後ろに、親画面の情報「?page=${親画面Url}等」を付与して読み込ませるとGAS側で認識することができます。それをWeb/javascript側に戻せば,把握できることになります。
GASの呼び出しロジックを書くと次のようになります。
①Web/javascript → GAS/doGet(e):iframe起動ソース
↙
②Web/javascript → GAS/doGet(e):GAS包括型HTML
↙
③Web/javascriptのiframe内でGAS包括型HTMLが動作
今回は,doGet関数を2回呼び出す方式にしましたが,fetchの場合に比べ読み込みコード量はそれほど変わらず,レスポンスに大きな影響は与えませんでした。
①の場合のdoGet(e):iframe起動ソースの読込スクリプト例は以下のような感じです。
①GAS doGet(e)の記述例
function doGet(e) {
try{
let jsText = HtmlService.createHtmlOutputFromFile('Widget2').getContent();
jsText = jsText.split("<script>").join("").split("</script>").join("");
// 送られて来た引数による任意の処理を入れる
const currentUrl = ScriptApp.getService().getUrl();
const finalJs = jsText.replaceAll('__WEB_APP_URL__', currentUrl);
return ContentService.createTextOutput(finalJs)
.setMimeType(ContentService.MimeType.JAVASCRIPT);
} catch(err) {
return ContentService.createTextOutput("Error: " + err.toString());
}
}
これは,Web/javascriptにfunctionソースコード「例ではWidget2.html」を読み込ますための書き方「getContent()を使う」です。
GASは読込ファイルにHTMLタブが無いとエラーになりますので,<script>~</script>で挟んだfunction関数文「Widget2.html」を用意し,読み込ませた後,split()メソッドで<script>と</script>を取り除いています。
GASのURLを「ScriptApp.getService().getUrl()」で取得し,replaceAllメソッドでソース内の”__WEB_APP_URL__”文字列にGASURLを埋め込んでます。
functionリソースを取り込むためreturn値が「ContentService.createTextOutput(finalJs)
.setMimeType(ContentService.MimeType.JAVASCRIPT);」となっていることに注意してください。
②の場合のGAS/doGet(e):GAS包括HTML用の読込スクリプトは以下のような感じです。
②GAS doGet(e)の記述
function doGet(e) {
try{
const template = HtmlService.createTemplateFromFile('iframe');
// 送られて来た引数による任意の処理を入れる
template.webAppUrl = ScriptApp.getService().getUrl();
return template.evaluate()
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
} catch(err) {
return ContentService.createTextOutput("Error: " + err.toString());
}
}
これは,Web画面側でiframeなどを使って,GASの包括的なHTML「例ではiframe.html」を起動する場合の書き方です。
この場合は,templateオブジェクトが使えますので,Web/javascript側で"<?!=
webAppUrl ?>"と書くと文字列が読み込まれます。
GASの包括的なHTML起動となるためreturn値が「template.evaluate()
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);」となっていることに注意してください。
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);」となっていることに注意してください。
①と②の切り替えは,Web/javascriptにおいて,doGet(e)を読み込むためのGASURLに「?mode=***」を追加してmode引数で選択させます。
なお,GAS側で作成した「Widget2.html」と「iframe.html」のスクリプトは,CSS等の記述があり冗長なので割愛します。実際には,Web画面のページURLを引数として渡したりしていますが,基本的な事項のみに絞って記述しました。
6. まとめ:今回の開発を終えて
Webサイト組み込み型チャットボットの開発は,GASとブラウザのセキュリティ仕様との戦いでした。
- ローカルテスト: file:// では動かないため,WSLによるローカルサーバ構築が必要でした。インターネット上にWebサーバを持っている場合はそれを使うとよいです。
- fetchの限界: PCで動いても,スマホブラウザではセキュリティ制限で不安定になりました。詳しい理由はわかりませんでした。
- 最終最適解: 外部からの通信にこだわらず,google.script.run を使ったGAS内包型にシフトしたことで,十分な安定性を手に入れました。
一見,遠回りをしたようですが,「なぜ動かないのか」をCORSや302エラーのレベルから深く理解できたことで,最終的に堅牢なシステム構成へ辿り着くことができました。
doGet(e)関数,doPost(e)関数,Web/javascriptのHTML文は,実際には非常に長く,これをそのまま書くと分かりくくなります。ポイントのみ記述していますのでご了承ください。
なお,参考として,fetchを使い続けるのならば,中継プログラム(サーバ)
にGASのデータを転送し,返答を受け取る方法があります。
この方法であれば,サーバ間の通信なので,ブラウザのCORSやITP(スマホ特有のセキュリティ)およびGASの302リダイレクトによる制限は一切受けないことになります。しかし,中継プログラム(サーバ)を別途用意する必要がありますので,今回はGASだけで解決する方法にこだわりました。
同じような課題にぶつかっている方の参考になれば幸いです!
次回は,GASのWebアプリのデプロイについて,触れておきたいと思います。
それでは楽しいITリテラシーライフをお過ごしください。
(ご注意)情報の正確性を期していますが,実施される場合には自己責任でお願いします。
0 件のコメント:
コメントを投稿