今回は技術Tipsではなく、個人的に試していて壁にぶち当たったのでアドバイスを
誰かから貰えればいいなと思い、記事にしています。
はじめに
環境
・一般共有不可、SSOを挟んでログインする必要のあるG Suite
・Google ドライブのドライブFileStreamなどインストール不可
・GASにて、doPost()で受け取ったデータをドライブに保存するウェブアプリ
つまり、未ログインの状態から何をするにもログインが求められるという環境だ。
やっていること
PCで生成したデータ(JSONなり、CSVなり)を上記G Suiteの環境のドライブにアップロードするべく、GAS製のウェブアプリへpostで通信。
また、これを自動化。
経緯
元々はPhantomJSを使って上記を実現していたのだが、Chromeが正式にヘッドレスモードを実装したこと、それにともないPhantomJSが開発終了したことを受け、同等の機能をChromeとNode.jsのChromyで実現しようとしていた。
//---PhantomJSの頃
var page = require('webpage').create();
//スクレイピング操作によるログイン操作など諸々
//...中略...
page.open(server_url, 'post', data.join("&"), function (status) {
...
});
PhantomJSのwebpageオブジェクトでは、openメソッドでpostによる通信ができたので、非常に使い勝手が高かった。
Chrome+Chromyになり、一通りの使い方を覚えて慣れてきた頃にPhantomJSで行っていた自動作業をいざ実装しようと試みた。
最初はNode.js内だから次のようにしようと試みた。
const webreq = require("request");
webreq.post({
url : server_url,
"headers" : {
"content-type" : "application/json"
},
body : JSON.stringify(data)
},(error,response,body)=>{
console.log("status="+error);
console.log("data="+body);
});
しかし、返ってきたのは、Google Driveへのログイン案内画面のHTMLだった。
そりゃそうだ。ログインしたセッションはChromy内でのみ生きているのだから。
それではChromyでPhantomJSのwebpageのopenメソッドに相当する機能があるのかと思い、Chromyの公式ドキュメントを見ても、それらしい機能はなさそう。
そこで、GAS製ウェブアプリにdoGet()でダミーの出力していたのを止めて正式にHTMLで画面を設置。その画面をChromyでスクレイピング操作しようとした。
しかし、GAS製のウェブアプリはiframeを2つ介しており、しかもアクセスが制限されている。そりゃそうだ。だからサンドボックスなのだから。
つまり、ChromyによるGAS製ウェブアプリのスクレイピングは不可能と考えられる。
そうなると、PhantomJSをNode.jsのChromyに置き換えて実現することは不可能なのか!?
終わりに
今回の問題は、Chromyでpost送信ができれば文句なしなのである。ただできるだけではNode.jsの標準機能なり別パッケージでいいが、今回重要なのは、次の点である。
Chromy(スクレイピングのエンジン?)でログインしたセッションを保ったままHTTP POST操作が行えること
PhantomJSではこれができたのだ。
そういう点では、スクレイピング操作とhttp postなどのプラスαが行えるPhantomJSは、実はものすごく利便性が高く貴重なのではないかと思えてきた。むしろ、最新のChromeと同じエンジンにして開発再開してほしい。
ファイルをアップするだけだったら、Google Drive APIなりをPythonなどで噛ませてやればいいじゃんとお思いだろう。しかしそっちでも結局の所、ログイン操作が必要となる。しかもAPIなどの操作のみではやりづらいサードパーティ製のSSOも挟んでいる関係上、各種APIオンリーでの実現も厳しいと思っている。
こと自分のような使い方と環境では、PhantomJSとGAS製ウェブアプリが非常にマッチしていた。
今回の自分の機能実現は、結局PhantomJSを使うことで切り抜けることを検討している。だがしかし、Chromyかあるいは他のスクレイピングパッケージで同等のことができないか、引き続き調査している。
続報
(2018/10/09追記)
Chromyでは限界があると感じ、もう一つのスクレイピングライブラリである、「puppeteer」を試すことにした。
こちらも同じNode.jsで動き、基本的な動作はChromyと大差ない。ソースコードに手直しが多少必要になるが、Chromyとpuppeteerの重要なオブジェクトの最初の宣言程度だろう。
PhantomJSのようにメソッドの時点でPOSTを扱えるという点は諦め、別途GSuiteのGASウェブアプリにPOST送信をするだけのHTMLを作成し、それを社内のテストサーバに配置した。
つまり、FormによるPOST送信を利用することにした。
この場合でも、ログインセッションがウェブサイトを遷移しても生きていることが重要だ。
//---chromyの場合
const chromy = new Chromy();
await chromy.goto(FIRST_SITE);
// ...途中の処理... G Suiteのログイン画面の操作含む
await chromy.goto(SECOND_SITE);
await chromy._evaluateWithReplaces(function(){
var arr = _arg.split(",");
document.getElementById("txt_func").value = "put";
document.getElementById("txt_target").value = arr[0];
document.getElementById("txt_body").value = arr[1];
document.getElementById("btn_send").click();
},{},
{"_arg":"'" + HOGE1 + "," + HOGE2 + "'"}
);
// ...puppeteerの場合
const browser = puppeteer.launch();
const page = await browser.newPage();
await page.goto(FIRST_SITE);
// ...途中の処理... G Suiteのログイン画面の操作含む
await page.goto(SECOND_SITE);
await page.evaluate(function(arr){
document.getElementById("txt_func").value = "put";
document.getElementById("txt_target").value = arr[0];
document.getElementById("txt_body").value = arr[1];
document.getElementById("btn_send").click();
},[HOGE1,HOGE2]);
G Suiteのログイン画面のスクレイピング操作後、適当なG Suiteのサイトを開いてから社内のテストサーバのテストサイトを開くというコードだ。通常のChromeでの操作通りなら、ログインセッションが生きたままであると期待を持つ。
結果として、Chromyはどうやらgotoをした時点でこれまでのページのセッションは切れてしまうようだった。おかげで、テストサイトからGASのウェブアプリにPOST送信しようとしても、スクリプトが存在しないという画面が表示されて怒られた。
対してpuppeteerでは、無事にテストサイトからGASのウェブアプリに POST送信成功 。結果用のページが表示された。puppeteerではBrowserやPage、Frameクラスと、構造がしっかり整っているおかげなのかもしれない。
puppeteer(とPOST送信用のHTML)を使えば、PhantomJS単独でできていた機能も、どうにか再現できそうだ。セッションをきっちり保てるpuppeteerのほうが、スクレイピング操作では利便性が高いと判断した。
(2018/10/10追記)
GASによるウェブアプリの構造をもう一度確認し、puppeteerでスクレイピングできないか試したところ、無事にiframeのセキュリティを超えて操作できたので追記しておく。
GASのおさらい
おさらいとしてGASのウェブアプリは、GAS上で次のようにして生成する。
function doGet(e) {
/*
... 必要ないくつかの処理...
*/
var ht = HtmlService.createTemplateFromFile("mainui.html");
return ht.evaluate().setTitle("HOGE").setSandboxMode(HtmlService.SandboxMode.IFRAME);
}
function doPost(e) {
var prm = e.parameters;
var opt = {};
for (var obj in prm) {
if (obj == "func") {
opt["func"] = prm[obj][0];
}else if (obj == "target") {
opt["target"] = prm[obj][0];
}else if (obj == "body") {
opt["body"] = prm[obj][0];
}
}
/*
...必要ないくつかの処理...
*/
//return ht.evaluate().setTitle("HOGE").setSandboxMode(HtmlService.SandboxMode.IFRAME);
return ContentService.createTextOutput(JSON.stringify(ret));
}
GASの同プロジェクト内にHTMLファイルを作成しておき、それをテンプレートとして呼び出し、サンドボックスの設定をして戻り値として返す。基本の構造はこれだけだ。後今回の目的としてPOSTで受け取ったデータをGAS上で整えてGoogle Driveに保存したいので、doPost関数も設定しておく。送信されるデータは上記のように受け取れることは、GASを触ったことがある人ならわかるはずだ。
doPostの戻り値は別にHTMLでもいいのだが、呼び出し側では単にコンソールで確認したいだけなので、ContentServiceで、プレーンテキストとして出力するようにした。
GASのウェブアプリの構造
こうして作成したウェブアプリを表示すると、見た目は普通のウェブサイトが出てくる。が、その中身は驚きのものだ。
<!-- 省略 -->
<iframe id="sandboxFrame" title="HOGE" allow=... src="https://..."></iframe>
<!-- sandboxFrameの中 -->
<html>
<head>...</head>
<body>
<iframe id="userHtmlFrame" allow=...></iframe>
</body>
</html>
<!-- userHtmlFrameの中 -->
<html>
<head>...</head>
<body>
<!-- GASプロジェクトのHTMLで記述したmainui.htmlの中身 -->
</body>
</html>
というように、iframeを2つ介している。それぞれセキュリティにより通常はスクリプト等で参照することはできないようになっている。
(chrome等のdevtoolでは、Javascript contextを変更することで操作は可能)
puppeteerによる参照
この2つのiframeをスクレイピングで操作して突破し、無事にGAS内の本来のHTMLにアクセスしたかったのだ。それがChromyだとサンプル通りにやってもうまく行かなかった。
そこでpuppeteerの出番だ。次のようにして無事に参照できた。
/* すでにPageオブジェクトまでは取得している前提 */
var fra = await page.frames(); //id=sandboxFrameが取得できる
for (var i = 0; i < fra.length; i++) {
var fra2 = await fra[i].childFrames(); //id=userHtmlFrameが取得できる
for (var j = 0; j < fra2.length; j++) {
var fra3 = await fra2[j].childFrames(); //GAS内のHTMLが取得できる
for (var k = 0; k < fra3.length; k++) {
await fra3[k].evaluate(()=>{
...
});
}
}
}
あえてforEach()等を使わなかった。コールバック関数を使ってしまうと、await/asyncの連続でやりたい処理によっては混乱してしまうので、昔ながらのfor文でわかりやすさを優先した。
Pageオブジェクトのframes()でそのページのすべてのFrameを取得できる。そして各Frameでは、childFrames()にてその中のFrameを取得できる。こうしてFrameを順にたどっていくことで、GAS内のHTMLに到達することができた。
最初だけframes()メソッドで、その先はchildFrames()メソッドという違いに注意したい。
それから最初のpage.frames()で、 実はGAS内のHTMLも取得できる のだが、それが順番が担保されているのかという点と、それが果たしてGAS内のHTMLであるという判定をどうやるかは人それぞれなので、今回の例では確実性を求め、全てのiframeを順に辿っていくやり方とした。
ここまでしてようやく無事にGAS製のウェブアプリにフルアクセスが可能になる。後はGAS内で定義した内容に応じてスクレイピング操作をしてあげるだけだ。
こうすることで、別にFormでPOST送信するHTMLを別途用意しなくても良いことがわかった。色々試していた先日できなかったのは、参照するFrameの数と内容を正しく追えていなかったからだった。上記コードでいうと、fra2を取得した時点で、
fra2.evaluate(()=>{ ... })
としてdocument以下を参照しようとしていた。実はさらにchildFramesで参照する必要があったのだ。
コツが分かれば、今後puppeteerとGASを組み合わせてさらに便利なスクレイピング・ウェブアプリの自動化ができそうだ。