はじめに
近頃のWebサービスはAPIが充実していて、いろいろなサービスと連携が取れるようになっている。
しかし、スタートしたてなWebサービスはAPIまで手が回らず、顧客要望に答えたほんの少しだけのAPIを提供するものも少なくない。
だけど、どうしてもSlackと連携して、色々と自動化したくなる時ってありますよね?
そんな状況に陥った自分が、APIが貧弱なWebサービスを無理やりSlackと連携させた時に取った方法を残しておこうと思う。
背景
価格も安く、機能的な要件も満たしていたので、とあるWebサービスを弊社で取り入れてみたは良いものの、招待だったり、Groupの作成・管理がどうも面倒になってきた。
ここに人の力を割いていては、エンジニアの名が廃るということで、自動化してやろうかと思った矢先...。
そのWebサービスのAPIドキュメントには、招待もGroup作成もAPIも存在しなかった。
しかし、一度やると決めたからにはなんとかして自動化したかったので、色々方法を模索してみた。
やったこと
方法1: formの送り先にリクエストを投げる
招待投げるFormだったり、groupを作成するFormが送っているパスに対して、全く同じリクエスト投げれば解決するじゃん!
と思ったので、早速調べてみたのだが。
どうも、そのサービスはモダンなフロントエンドで書かれており、招待やGroupの作成はボタンのonClickイベントで発火するようになっていて、formタグなど見当たらなかった...。
こうなったら、JSを読むしかないか!?と思ったのだが、コンパイルされたJSを読めるはずもなく、あえなく断念。
方法2: Headless Browserを使う
JSで動いてるならHeadless Browserを使えばよいじゃん!と思ったので、早速やってみることにした。
使ったHeadless BrowserはPuppeteerというGoogleさんが出しているChromeのHeadless Browser。
Googleさんが出すHeadless Browserということで、さぞ、有能なんでしょう??ということで決定。
実際に、使ってみると、めちゃくちゃ使いやすくて、約1〜2日くらいで、Group作成、Groupにメンバーを招待する機能を作成することに成功。
その時のコードがこちら
async setup() {
let browser = await setup.getBrowser()
let page = await browser.newPage()
return {browser, page}
}
async login(page) {
let url = _url.resolve(this.endpoint, 'sign_in')
await page.goto(url)
await page.type('input[name="id"]', this.login_id)
await page.type('input[name="password"]', this.login_pass)
await page.click('input[type="submit"]')
await page.waitForNavigation()
}
async createGroup(name) {
let {browser, page} = await this.setup()
try {
let url = _url.resolve(this.endpoint, 'group')
await this.login(page)
await page.goto(url)
await page.type('.group_name', name)
await page.click('button.submit')
} catch (err) {
console.error(err)
throw err
} finally {
await browser.close()
}
}
まず、ログインして、group作成ページへ行き、group名を入力して、submitボタンを押すという、人間がブラウザ上でやる手作業を再現した形になる。
puppeteerではbrowserオブジェクトの生成時にオプションとして headless: false
を渡すと、ブラウザが立ち上がり、puppeteerで記述した操作の様子を見ることが出来る。
※ headless: true
にする時は slowmo
オプションを付けないと高確率でChromiumが落ちます
問題点
Groupの作成は文字を入力してsubmitするだけで済んだので、とても簡単だったのだが、メンバー追加や、メンバー招待になってくると、Slackのメンバー情報から、そのWebサービスのメンバー情報を紐付けなければならず、不幸な事にそのWebサービスのAPIにはユーザ系のAPIは1つも存在しなかった。
そのため、Slackのメンバー情報からemailアドレスを取得し、メンバー検索画面で、emailを入力して検索して、紐付けを行っていた。
しかし、やりたい事にたいしてコストが高いし、案の定めちゃくちゃ遅かった。
また、これはJSで書かれていたせいでもあると思うが、たまにボタンが上手く動かなかったり、そもそもレンダリングがされないということが多々起こっていた(Puppeteerからではなく、普通のブラウザで操作している時にも起こっていた)。
動作は全く安定しなかったので、リトライ機構を作ってみたは良いものの、約50%くらいの確率でエラーになり、リトライしていたので、招待されるまでに遅延が起こったり、Groupが作成されなかったり(大体一回の処理時間が20s~30sくらいだった)。
とにかく遅いし、動作を安定化させたかったので、別の方法を考えてみた。
方法3: 初心に帰る
方法2で問題にぶち当たったので、一旦初心に帰ることに。
方法1で駄目だったのは、Formタグがなかったのでリクエストの送り先や、リクエストボディーのフォーマットがわからなかったことだ。
...あれ?これってブラウザから送られるリクエストの内容取得できればわかるんじゃね?
早速試してみることに、Chromeのdeveloper toolを開き、networkタブを開く。
login画面にて、ログインをしてみると...、リクエスト内容全部取れた!!!
headless browserなんか使わなくても、できそう...。
早速やってみることに。まずは、ログインから。
Chromeのdeveveloper toolのリクエスト内容を見ると、リクエストボディーの内容はAuthenticate TokenとIDとPassword、後はパスワードを覚えておくかのフラグの4つだった。
Authenticate Tokenはhtml内に合ったcsrf tokenと一致していたので、スクレイピングして取得。
リクエストヘッダーを見ると、 referer
と origin
がセットされていたので、変なリクエストだと疑われないように、念のためセット。
あとは、sessionがcookieに保存されていたので、cookieの情報をリクエストに含めるようにした。
早速リクエストを送ってみることに。
実際のコードがこちら。
httpクライアントには request-promise
を使ってます。
constructor() {
this.base_url = config.BASE_URL
this.login_id = config.USER_ID
this.login_password = config.USER_PASSWORD
this.user_agent = config.USER_AGENT
this.cookiejar = rp.jar()
this.csrf_token = null
this.default_headers = {
'origin': this.base_url,
'user-agent': this.user_agent
}
this.request = this.initialize()
}
initialize() {
return rp.defaults({
resolveWithFullResponse: true,
followAllRedirects: true,
jar: this.cookiejar,
transform: this.saveCsrfToken.bind(this) // csrf tokenをスクレイピングする関数
})
}
async startSession() {
let url = _url.resolve(this.base_url, 'sign_in')
let headers = Object.assign(this.default_headers, { 'referer': url })
await this.request({uri: url, headers: headers})
}
async login() {
let url = _url.resolve(this.base_url, 'sign_in')
await this.startSession()
let headers = Object.assign(this.default_headers, { 'referer': url })
let body = {
'id': this.login_id,
'password': this.login_password,
'authenticity_token': this.csrf_token,
'remember': 1,
}
let option = {
uri: url,
method: 'POST',
headers: headers,
formData: body
}
await this.request(option)
}
まず、最初にsign_inページにGETリクエストを飛ばす。
すると、csrf token付きのhtmlとsession cookieが発行される。
その2つを保存したら、sign_inページへcsrfトークンとsession cookieの情報を乗せてPOSTリクエストを送る。
sign_inは普通のformリクエストだったので、 request
のoptionに formData
でリクエストBodyを付けてあげる。
そうすると、 Content-Type
が勝手に application/x-www-form-urlencoded
になり、良い感じにリクエストBodyを変換してくれる。
sigin_inに成功すると、session cookieとremember_tokenがcookieに保存される。
csrf-tokenとsessionはリクエストを送る度にかわるので、毎回保存するようにしている。
次に、Group作成の方のリクエストをChromeのdeveloper toolから見てみる。
リクエストの送り先を見てみると、 https://[base_uri]/api/group
になっていた。
Content-TypeはJsonで、今度はcsrf-tokenをリクエストヘッダーにつけていた。
async createGroup(name, description = '') {
let url = _url.resolve(this.base_url, 'api/group')
await this.login()
let headers = Object.assign(this.default_headers, {
'referer': url,
'content-type': 'application/json',
'X-CsrfToken': this.csrf_token
})
let body = {
group: {
name: name,
description: description
}
}
let option = {
uri: url,
method: 'POST',
headers: headers,
body: JSON.stringify(body)
}
await this.request(option)
}
一回ログインして、session cookieとcsrf-tokenを取得し、リクエストヘッダーにcsrf-tokenをセット。
本当は、 request
の json
オプションを使用すれば、 Content-Type
も勝手に application/json
になるし、bodyも良い感じに変換してくれるのだが、どうやら二階層以上のObjectはちょっと工夫が必要だった。
調べてもあんまりわからなかったので、ゴリッと書くことに。
リクエストを投げてみると、ちゃんとGroupが作られている!!!
puppeteerを使っているときに比べて、実行時間が半分以下になったし、何より動作が安定するようになった。
まぁ、ブラウザがやってる事をプログラムで書いただけなので、動くのは当たり前なんだけどw
まとめ
色々右往左往したが、なんとかSlackと連携させることが出来た。
APIが貧弱なサービスでも、ブラウザから操作さえできれば、自動化できることがわかったので、今後も諦めずに作っていこうと思う。
これをやってよかったのは、今まで知識でしかなかったSession CookieやCSRF Tokenなどが、実際に触ることで、知見に昇華したことだ。
また、意図せず、Puppeteerも触れたこともよかった。
Puppeteerに限らずHeadless Browserはテストやクローリングには使えそうだけど、こういう疑似APIには向いていないことを実感した。
POSTリクエスト送る事がメインなんだったら、Headless Browser使う必要ないもの。
最後になりますが、この記事を読んで下さった方も、APIがないからとサービスを非難するのではなく、温かい目で初期段階だからこっちでなんとかしてやろうと見守って上げる心を意識してくださると幸いです。