iTunesで現在再生中の曲をslackに投げるMacアプリをswiftで組んだ話

仕事場でiTunesからの自動選曲を使ってBGM流してるので、再生中の曲をslackで確認できるようにするbotを作った時の話。

github.com

これを書いた前後でswift 3に移行したりしたのでコードの内容が中途半端なんだけどもそこは見なかったことに。

このネタの肝は以下の二つ。

  • DistributedNotificationCenter経由でiTunesからの再生中トラックの変更通知を受け取る
  • 受け取った通知から曲情報を取り出してslack APIを叩く

再生中のトラックの変更通知

qiita.com

にほぼまま倣い。

        DistributedNotificationCenter.default().addObserver(self,
                                                                                                selector: #selector(AppDelegate.onChangeTrack(_:)),
                                                                                                name: Notification.Name(rawValue: "com.apple.iTunes.playerInfo"),
                                                                                                object: nil)

曲情報の取得

    func onChangeTrack(_ notification: Notification?) {
        let userInfo = (notification as NSNotification?)?.userInfo
        print(userInfo ?? "")
        
        if (userInfo!["Player State"] as! String) == "Playing" {
            guard let
                displayLine0: String = userInfo!["Display Line 0"] as? String,
                let displayLine1: String = userInfo!["Display Line 1"] as? String,
                let storeURL: String = userInfo!["Store URL"] as? String else {
                    return
            }

NotificationのuserInfoに諸情報が収納されているのだが、このDictionaryオブジェクト内に「どの情報がどのキーで」収められているのかの明確な定義情報がどうやら見当たらない。なので上の実装は「とりあえずのもの」である。

userInfo["Player State"]が"Playing"かどうかで場合分けをしているのも、これ以外の状態が入った通知が飛んできた場合にも通知を受け取ってしまって予期しないslack送信にならないようにしたものだが、この辺の挙動も明確なドキュメントがないのが辛いところ。

slack APIへの送信

            let hookURL = UserDefaults.standard.string(forKey: "hookURL")!
            let params: [String: Any] = [
                "channel": UserDefaults.standard.string(forKey: "channel")! as AnyObject,
                "username": "nowplayingbot" as AnyObject,
                "text": String(format: "%@ %@", arguments: [formatter.string(from: Date()), displayLine0]) as AnyObject,
                "attachments": [[
                    "title": displayLine1,
                    "title_link": httpStoreURL // 上で取得したstoreURLのschemeを"itms://"から"http://"に差し替えたもの
                    ]]
            ]
            let request: NSMutableURLRequest = NSMutableURLRequest(url: URL(string: hookURL)!)
            
            request.httpMethod = "POST"
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")
            request.addValue("application/json", forHTTPHeaderField: "Accept")
            do {
                request.httpBody = try JSONSerialization.data(withJSONObject: params, options: JSONSerialization.WritingOptions.init(rawValue: 2))
            } catch {
                // Error Handling
                print("NSJSONSerialization Error")
                return
            }
            session.dataTask(with: request as URLRequest, completionHandler: { data, response, error in
                // code
            }).resume()

まず、このhookURLというのはslackで用意される「Custom Integrations」「Incoming Webhooks」を使って生成する。このAPI hookなど認証情報が絡む箇所はUserDefaultsに逃して(さらに初期plistファイルをgitignoreに入れてリポジトリに入らないようにして)ある。

で、生成したNSURLRequestインスタンスにbodyとしてJSON化したparamsを付与しPOST送信。ご覧の通り送信完了時及びエラー時処理は簡易実装のため入れてない点は許して。

qiita.com

なおparamsでご覧の通りattachmentsを使っているのでちょっとリッチなメッセージを送ることができる。

f:id:zvorak:20170704155847p:plain

たまに訳のわからない選曲がぶっこまれても安心。

Poltergeistを使ってスクリーンショットをとろう、そして現れる日本語フォント問題

前項ついでにもう一つPoltergeistネタ。デバッグも兼ねてスクショを撮りながら作業していたら日本語フォントが入っていないと該当部分がカラになってしまう現象に遭遇した。

      session.save_screenshot('ss.png', full: true)

動作がAmazon EC2上だったのでまあそうだろうなと思いつつ下記の2フォントをインストールして解決した。

$ sudo yum install ipa-gothic-fonts
$ sudo yum install ipa-mincho-fonts

Macでも同じような対応が必要なのかどうかは調べてない。その時はPoltergeist以外を使うといいよとかそもそもHeadlessにこだわらなくてもよくね?言われる気がするけど。

Capybara+PoltergeistでCSVダウンロードをするためにやった(割とマニア向けかもしれない)こと

このCP組み合わせ自体は需要がマニヤックすぎる気が大いにするんだが、個々の要素的には汎用性が高いんじゃないかと。たぶん。

  • 対象ページ(サイト)が要ログイン領域
  • CSVダウンロードをフォーム送信によって起動
  • さらにそのフォームがsubmitではなくaタグクリックによってJS呼び出し、JSによるsubmit()呼び出しでPOST送信

既存のとあるECサイトから販管データをCSVでダウンロードする、これを日次でバッチ化するだけの簡単なお仕事。ログインを要するだけならまだ話は簡単なのだが、そこから先が随分な変態構成で変な涙出た。

まずは骨組みを作る。最初はこのページの記述に沿って組んだ。

qiita.com

Gemfileがこちら。あとで構成管理しやすいように、よっぽどすぐに捨てるの前提なスクリプトでない限りはGemfileを置いてる。

# frozen_string_literal: true
source "https://rubygems.org"

# gem "rails"

gem "capybara"
gem "poltergeist"

あとnpmでPhantomJSをインストールしておく。

$ npm install phantomjs -g

ログインページ

# coding: utf-8
require 'capybara'
require 'capybara/poltergeist'

class CSVBot
  def self.execute
    Capybara.register_driver :poltergeist do |app|
      Capybara::Poltergeist::Driver.new(app, {:js_errors => true, :timeout => 1000 })
    end
    Capybara.default_selector = :xpath
    session = Capybara::Session.new(:poltergeist)

    session.driver.headers = { 'User-Agent' => "Mozilla/5.0 (Macintosh; Intel Mac OS X)" } 

    session.visit = "https://xxx.com/admin/" #ログインページに行って
    session.fill_in "userid", with: "foobar" #IDを入力
    session.fill_in "password", with: "******" #パスワードを入力
    session.find(:xpath, "//a[@href='yyy']").click #ログインボタンを押す

    # ...
  end
end

とりあえずここまででログインは完了。サイトによっては「ログインボタンを押す」のところでaタグではなくsubmitボタンへのxpathを通したりする必要があると思われるがそこは適宜。User-Agentも適当でいいと思うよ。

ダウンロードフォームのJavaScriptを「乗っ取り」

<div class="buttonA"><a href="javascript:csvdownload()"><img src="/images/csvdownload.png" class="alpha"></a></div>
function csvdownload() {
    // フォームパラメータを色々といじってる

    document.form1.submit();

    // 後処理
}

上記のように対象となるフォームがdocument.form1だったのは把握していたので、最初falseを返すonsubmitコールバックを登録して置いて単純にsubmit遷移のみを阻止し、フォーム送信部分は別途処理しようと思っていたら不発。onsubmitはJSからの呼び出し時にはコールバックされないことを見逃していた。

stackoverflow.com

Browsers don't fire the form's onsubmit handler when you manually call form.submit().

さて困った。csvdownload()の内容は送信パラメータの内容を生かすためできるだけ残したい上に、submit()呼び出しの部分はサイト側のJSなので編集することができない。submit()さえ居なければ。お兄ちゃんどいてそ(ry

結局どうしたか。

submit()ごと乗っ取ってしまえ。

    # ...
    session.execute_script(%!
      document.form1.submit = function() {
        var kvpairs = [];
        var f = document.form1;
        for (var i = 0; i < f.elements.length; i++) {
          var e = f.elements[i];
          kvpairs.push(encodeURIComponent(e.name) + '=' + encodeURIComponent(e.value));
        }
        var q_str = kvpairs.join('&').replace(/%20/g, '+');

        var xhr = window._xhrDownloadCSV;
        xhr.open(f.getAttribute('method'), f.getAttribute('action'), false);
        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
        xhr.send(q_str);
        return false;
      }
      window.simulateDownloadCSV = function() {
        window._xhrDownloadCSV = new XMLHttpRequest();
        csvdownload();
        return window._xhrDownloadCSV.responseText;
      }
    !)
    csv = session.evaluate_script("simulateDownloadCSV();")

csvdownload()の直前でXMLHttpRequestインスタンスを生成してフォーム送信を代行、乗っ取った偽submit()の中でそのXHRインスタンスを使ったPOSTを送信。csvdownload()でform1のパラメータを準備する処理はそのままなので、準備が整いdocument.form1.submit()に入ったあとにその準備済みのパラメータを取り出してXHRにくれてやる。csvdownload()を抜けたらHTTP応答で取得したresponseTextからCSVを取り出せば良い。

だいぶ托卵感が強いな。

Shift_JISの罠

ここまではそこそこすんなりたどり着けたが、最大の問題はここからである。

ダウンロードしてみると容赦ない文字化けの嵐。どこでエンコーディングミスが発生しているのかが特定できず難儀したがsimulateDownloadCSV()の中でcharCodeAt()などを使って調べるとそもそもJSの中ですでに化けている。そうだった。CSVというくらいで文字コードUTF-8とは限らないのだった。それでもContent-Typeでcharsetが入っていればまだマシなんじゃないか?と思いつつ応答ヘッダを見る。

HTTP/1.1 200 OK
Date: Tue, 03 Jul 2017 08:50:24 GMT
Server: Apache
X-Frame-Options: SAMEORIGIN
Content-Disposition: attachment; filename=download.csv
Pragma: private
Cache-control: private, must-revalidate
Expires: 0
Content-Length: 4825
Connection: close
Content-Type: application/octet-stream

まあ、ダウンロードならraw dataを保存するだけcharsetいらないもんね…。やっぱり入ってなかったね…。そりゃエンコーディング判別失敗するよね…。

こういう時のためのoverrideMimeType()である。

qiita.com

XHRでopen()からsend()までの間に、overrideMimeType()を追加して応答時のMIMEタイプを上書きする。Shift_JISデータ列としてXHRに読み取らせることでエンコーディング誤検出を防ぐというもの。

        xhr.open(f.getAttribute('method'), f.getAttribute('action'), false);
        xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
        xhr.overrideMimeType("text/csv; charset=Shift_JIS");
        xhr.send(q_str);

これでsimulateDownloadCSV()の返す内容は正常なCSVデータとなり、所定の目的を完遂することができましたとさ。