インターネットラジオを全録音するソフトつくった

Net Radio Archive (https://github.com/yayugu/net-radio-archive) というソフトを作りました

特徴

番組表をいい感じにスクレイピングして、番組名などが付いた良い感じのファイルを出力してくれます。

...
2015_05_08_1200_村川梨衣の_a_りえしょんぷり~ず♡_村川梨衣.mp4
2015_05_08_1230_小澤亜李・長縄まりあのおざなり_小澤亜李、長縄まりあ.mp4
2015_05_08_1300_A&G_ARTIST_ZONE_THE_CATCH__AiRI_AiRI.mp4
2015_05_08_1430_A&G_NEXT_BREAKS_松田利冴のFIVE_STARS.mp4
2015_05_08_1500_金田朋子・保村真のエアラジオ_金田朋子、保村真.mp4
2015_05_08_1530_水島大宙・木村良平_←SIDE_BY_SIDE→_水島大宙、木村良平.mp4
2015_05_08_1600_超!A&G+_スペシャル.mp4
2015_05_08_1700_角元明日香の本気!アニラブ_角元明日香.mp4
2015_05_08_1730_高橋美佳子のマルごとぴ〜なっつ!_高橋美佳子.mp4
2015_05_08_1800_A&G_ARTIST_ZONE_THE_CATCH__鷲崎健_鷲崎健.mp4
2015_05_08_1930_A&G_NEXT_BREAKS_吉田有里のFIVE_STARS.mp4
2015_05_08_2000_三上枝織の_A&G_NEXT_GENERATION_Lady_Go!!_三上枝織.mp4
2015_05_08_2100_黒崎真音のRADIO_RONDO_ROBE~妄想王女の単独電波~_黒崎真音.mp4
...

今までの似たソフトだと時刻を指定したりして録画・録音するものが多かったのですが、そうではなく全部とっておきたい、番組タイトルも自動で付けて欲しいと思いつくりました。

現在対応しているもの

動かし方

Linuxなどで動きます。

24時間起動するマシンが必要です。またRadikoのエリア判定がいい感じとなるIPのサーバーがいいでしょう。

自分ように作っているものなのでパッケージングが十分とは言いがたく、他環境で動かすと問題がでてくるかも知れませんのでgithub issueなりTwitter (@yayugu) なりでお気軽にご相談ください。

github.com

追記:
ライセンス付けました。MITです

超A&G+の新しい番組表(2015/04現在)をスクレイピングする

4月の番組改変に合わせて番組表が新しいやつになっていて悲鳴あげながら直しました。 前の番組表は1つの曜日が1つのtableにマッピングされていて楽だったのですが、 新しいやつは

  • 全曜日で1テーブル
  • 行(tr)は曜日ではなく時刻ごと。その中に全曜日の番組tdで入っている
  • rowspanで行の結合があるため、それを考慮した2次元配列を作っておかないと曜日の判定がずれる

となかなか凶悪な仕様になっております。

自分が作っているラジオ録画ソフトではこんな感じで対応した。

こんかいの対応にかかったコーディング時間は2時間ほど。 こういうのは反射神経が重要なので、新しいやつが出たときにババっと書き捨てられる力をつけていきたいものです。

require 'net/http'
require 'time'
require 'chronic'
require 'pp'
require 'moji'

module Ag
  class Program < Struct.new(:start_time, :minutes, :title)
  end

  class ProgramTime < Struct.new(:wday, :time)
    SAME_DAY_LINE_HOUR = 5

    # convert human friendly time to computer friendly time
    def self.parse(wday, time_str)
      time = Time.parse(time_str)
      if time.hour < SAME_DAY_LINE_HOUR
        wday = (wday + 1) % 7
      end
      self.new(wday, time)
    end

    def next_on_air
      time = chronic(wday_for_chronic_include_today(self[:wday]))
      if time > Time.now
        return time
      else
        chronic(wday_to_s(self[:wday]))
      end
    end

    def chronic(day_str)
      Chronic.parse(
        "#{day_str} #{self[:time].strftime("%H:%M")}",
        context: :future,
        ambiguous_time_range: :none,
        hours24: true,
        guess: :begin
      )
    end

    def wday_for_chronic_include_today(wday)
      if Time.now.wday == wday
        return 'today'
      end
      wday_to_s(wday)
    end

    def wday_to_s(wday)
      %w(Sunday Monday Tuesday Wednesday Thursday Friday Saturday)[wday]
    end
  end

  class Scraping
    def main
      programs = scraping_page
      programs = validate_programs(programs)
      programs
    end

    def validate_programs(programs)
      if programs.size < 20
        puts "Error: Number of programs is too few!"
        exit
      end
      programs.delete_if do |program|
        program.title == '放送休止'
      end
    end


    def scraping_page
      html = Net::HTTP.get(URI.parse('http://www.agqr.jp/timetable/streaming.php'))
      dom = Nokogiri::HTML.parse(html)
      trs = dom.css('.timetb-ag tbody tr') # may be 30minutes belt
      two_dim_array = table_to_two_dim_array(trs)
      two_dim_array.inject([]) do |programs, belt|
        programs + parse_belt_dom(belt)
      end
    end

    def parse_belt_dom(belt)
      belt.each_with_index.inject([]) do |programs, (td, index)|
        next programs unless td
        wday = (index + 1) % 7 # monday start
        programs << parse_td_dom(td, wday)
      end
    end

    def table_to_two_dim_array(trs)
      aa = []
      span = {}
      trs.each_with_index do |tr, row_n|
        a = []
        col_n = 0
        tr.css('td').each do |td|
          while span[[row_n, col_n]]
            a.push(nil)
            col_n += 1
          end
          a.push(td)
          cspan = 1
          if td['colspan'] =~ /(\d+)/
            cspan = $1.to_i
          end
          rspan = 1
          if td['rowspan'] =~ /(\d+)/
            rspan = $1.to_i
          end
          (row_n...(row_n + rspan)).each do |r|
            (col_n...(col_n + cspan)).each do |c|
              span[[r, c]] = true
            end
          end
          col_n += 1
        end
        aa.push(a)
      end
      aa
    end

    def determine_wday(index, padded)
      wday = index - 1 % 7 # monday start
    end

    def padded?(td)
    end

    def parse_td_dom(td, wday)
      start_time = parse_start_time(td, wday)
      minutes = parse_minutes(td)
      title = parse_title(td)
      Program.new(start_time, minutes, title)
    end

    def parse_minutes(td)
      rowspan = td.attribute('rowspan')
      if !rowspan || rowspan.value.blank?
        30
      else
        td.attribute('rowspan').value.to_i * 30
      end
    end

    def parse_start_time(td, wday)
      ProgramTime.parse(wday, td.css('.time')[0].text)
    end

    def parse_title(td)
      [td.css('.title-p')[0].text, td.css('.rp')[0].text].select do |text|
        !text.gsub(/\s/, '').empty?
      end.map do |text|
        Moji.normalize_zen_han(text).strip
      end.join(' ')
    end
  end
end

元コード

https://github.com/yayugu/net-radio-archive/blob/67990ee0cbb7b5ff3bdc89465643a9d936c42d12/lib/ag/scraping.rb

悲しみのdiff

Follow the A&G+ new timetable!!!!!!!!!!!!!! · yayugu/net-radio-archive@67990ee · GitHub

響 HiBiki Radio Station をaacで録音する

2015/11/10追記

響がリニューアルしたんでこの記事の情報は全く役に立たなくなりました。 新しい仕様についての説明、コードはこちら↓

vector.hateblo.jp

検索用キーワード: Mac, Linux, rtmp, rtmpdump, rtmpe, 保存

まえがき

響で再エンコードなしでaacを取得する方法がわかったんでメモ。

ググるwmaやm3u8で取得する方法は見つかるんだが、wmaだとiOSとかで再生できないし、再エンコードは気分が悪い。m3u8 (HTTP Live Streaming)はなんかファイルがすごい分割されていてパッとググってもffmpegとかでいい感じにmp4などに復元する方法が見つからなかったので遠慮したさがあった。

コード

雑なコードとしては下の2ファイルを見て欲しい

https://github.com/yayugu/net-radio-archive/blob/18d9097df6266f8ece9e026b1414768eb82e26d8/lib/hibiki/scraping.rb

https://github.com/yayugu/net-radio-archive/blob/18d9097df6266f8ece9e026b1414768eb82e26d8/lib/hibiki/downloading.rb

手順

http://hibiki-radio.jp/get_program/$(WEEKDAY)

にアクセスすると曜日ごとの番組情報が取得できる。$WEEKDAYは数字で1-6がそれぞれ月〜金と土日に対応している。

Aタグのonclickが onclick="AttachVideo('garo','1637','1','0')" みたいになっているのでこの関数の1つめと2つめを取り出す。

m = /AttachVideo\('(.+?)','(.+?)','.+?','.+?'\)/.match(onclick_text)
short_name = m[1]
channel_id = m[2]

その情報からURLを生成してアクセスする。

      uri = URI.parse("http://image.hibiki-radio.jp/uploads/data/channel/#{base.short_name}/#{base.channel_id}.xml")

      res = Net::HTTP.get_response(uri)
      unless res.is_a?(Net::HTTPSuccess)
        return nil
      end

      dom = Nokogiri::HTML.parse(res.body)

      protocol = dom.css('protocol').text
      domain = dom.css('domain').text
      dir = dom.css('dir').text
      flv = dom.css('flv').text
      if protocol.blank? || domain.blank? || dir.blank? || flv.blank?
        return nil
      end
      m = /^.+?\:(.+)$/.match(flv)
      filename_query = m[1]
      rtmp_url = "#{protocol}://#{domain}/#{dir}/#{filename_query}"

xmlのような雰囲気のものが取り出せる。404だったり、情報が空のこともある。その場合はその番組はおそらく配信していない。 無事取得できて、以下のようになってたら成功。この情報を組み立てるとrtmpのurlができる。

<data>
        <protocol>rtmpe</protocol>
        <domain>cp209391.edgefcs.net</domain>
        <dir>ondemand</dir>
        <channel type="main">
                <flv>mp4:150101_lovelive_ms_150101_lovelive_ms.mp4?di=910&si=609&pi=2806&gi=6494&gc=3&bi=34236&bc=lovelive_ms&ei=921385&ec=150101_lovelive_ms&vi=4989926&vc=150101_lovelive_ms&msi=516&mc=&ni=1625</flv>
                <thumbnail>http://image.hibiki-radio.jp/uploads/radio_program/flash_image/c7562a9a9a67e099402d472585bcdc5068da0d24.jpg</thumbnail>
        </channel>
</data>

組み立てたurlをrtmpdumpに渡す

`rtmpdump -q -r #{Shellwords.escape(rtmp_url)} -o #{Shellwords.escape(flv_path)}`

うまくいっていればflvファイルがあるはずである。中身はh264の映像とaacの音声である。h264の映像はダミーであるため、ffmpeg(or avconv)でaacだけを取り出す。

`avconv -loglevel error -y -i #{Shellwords.escape(flv_path)} -acodec copy #{Shellwords.escape(aac_path)}`

あとがき

ちなみにrtmpもhls(HTTP Live Streaming)もデータを転送するための方法であり、中身は大抵の場合h264とaacである。 なのでどちらでもmp4が取り出せるはず(方法は知らないけど)

VMで開発してるんだけど IntelliJ / PHPStorm とかを使いたい

xxxa: dockerコンテナ側のディレクトリをローカルmacにマウントしたいんだけど 
xxxa: -v, --volume=[] Bind mount a volume (e.g., from the host: -v /host:/container, from Docker: -v /container) 
xxxa: -v /container してもローカルmacに表われないんですけどどうしたらいんですか 
xxxb: boot2docker? 
xxxa: はい
yayugu: 多分出来ない 
xxxa: そうなんですね 
yayugu: boot2dockerに多くを期待してはいけません 
xxxa: intelliJ使いたいんだけどなあ・・・
yayugu: っsshfs 
yayugu: sshfsクソ遅いということは知りながら書いてる 
yayugu: この辺についてはおおいに問題なんですけど 
yayugu: まあ現実解としては4つくらいしかないくて 
yayugu: 1. Linuxをマシン直で使う 
yayugu: 2. sshfsで転送遅いのを我慢する 
yayugu: 3. ローカルにgit置いてIntelliJの機能(SFTP)でlinuxマシンに転送する。ラグいのは我慢する。PHPUnitなどの連携がクソるのも我慢する 
yayugu: 4. Macで動くようにする 
xxxc: 3がオススメだ 
yayugu: 1ができると最高なんだが、俺はムリです。X window systemキツイ
xxxc: 無理して1にする必要も感じないな。開発用のライブラリとか突っ込んでいくとサーバーと同じ環境じゃなくなっていく。 
xxxa: 1、江添さんっぽい 
xxxc: 1でやったとしても VM 立てたくなる。 
xxxb: というかローカル開発じゃなくてローカルVM開発がもう基本なのか。4だと思ってた 
xxxc: ほら、ローカルにいろいろインストールしたくないじゃない。 
xxxb: はい
xxxc: プロジェクトごとにまっさらな環境でやりたいじゃない。 
xxxb: はい
yayugu:  1でdocker使うとVM立てなくてもxxxaが最初にやろうとしてたことできるよ

悠木碧ちゃんの曲がすごく良い

さいきん碧ちゃんの曲きいてるんですが熱くたぎるような曲からかっこいい曲、脳が溶けるような曲まで揃ってて、いい曲に恵まれてるなあと思うことしきり

燃える

撃枪・ガングニール - YouTube

Senki Zesshou Symphogear G Character Song 2 OST Soundtrack 正義を信じて、握り締めて - YouTube

まどマギと双璧をなす碧ちゃんの代表作であるシンフォギア。ストーリーとか急に歌うところのアレさに埋もれがちなんですが曲もすごくいいんですよ。

特にライブでの絶唱がこころにグッとくる。

シンフォギア ライブ 2013 [Blu-ray]
キングレコード (2014-07-30)
売り上げランキング: 1,409

かっこいい

ジェットコースターと空の色 (プティパ収録曲) シュガーループ (プティパ収録曲)

プティパ
プティパ
posted with amazlet at 14.10.26
悠木碧
flying DOG (2012-03-28)
売り上げランキング: 217,175

全部DECO27さんの曲になっちゃったんですが、DECO27さん本当に最高で引っ張ってきたプロデューサー敏腕

脳とけ系

悠木 碧「回転木馬としっぽのうた」MUSIC VIDEO - YouTube

プティパ
プティパ
posted with amazlet at 14.10.26
悠木碧
flying DOG (2012-03-28)
売り上げランキング: 217,175

ソロデビューアルバムの曲。碧ちゃんのソロ活動は独特のワールド展開してる

悠木碧 2ndSingle「クピドゥレビュー」Music Video short ver. - YouTube

クピドゥレビュー(初回限定盤)(DVD付)
悠木碧 吟遊院芹香(悠木碧)
flying DOG (2014-04-30)
売り上げランキング: 8,802

あ゛~幸せ

「ポポン...ポン!」悠木碧 Music Video - YouTube

というわけで

ファンクラブ結成 & ソロライブも決まってますます加速していくっぽい碧ちゃん たのしみや~

ところで、この2000年代前半みたいなホームページなんとかしてあげて欲しい http://aoitown.com/frame.htm

RailsでStrutsの再発明

真面目な話mixinだと複数の実装を依存関係なく共有できるので、実装の共有を継承ツリーでしか表現できなかったJavaStrutsより便利だと思う。

ユーザー認証の手抜き

Webアプリ作っているといろんな局面でユーザー認証が必要になる局面がある。まじめにつくると果てしなく面倒だし、適当につくるとセキュリティ上問題になるので、要件に応じて適切に手抜きする必要がある。

適当なやつからしっかりしたやつまでなんとなくソートしていくとこんなかんじだと思う。

  • 認証なし
  • IPで弾く
  • Basic認証ソースコード、設定ファイルにパスワードベタ書き)
  • Basic認証(DBにUserテーブルをつくってパスワードを保存。追加はcliとかで手動)
  • login/logout画面作成。cookieなりmemcacheなりにセッションを保存
  • webからユーザーを追加できるように
  • password変更機能
  • OAuth
  • OpenID
  • mailを送ってリンクをクリックさせてメールアドレスの所有確認
  • メールアドレス変更機能
  • メールを使ってのパスワードリセット機能
  • OAuthで作ったアプリへの後からのメールアドレスとパスワード追加登録機能
  • 二段階認証

不特定多数のユーザーが登録する場合に開発として楽なのはid/password方式。
メールアドレス認証とかがない純粋なidだとすごい楽です。
こういうときOAuth選びがちだけど意外と使い勝手悪い。

OAuth使うと発生する問題

  • ライブラリの依存とか諸々ではまりやすい
  • OAuth provider (TwitterとかFBとかギッハブとか)に依存することになる
  • 複数のOAuth providerに対応すると1人のユーザーが複数アカウント重複してしまう可能性がでてきてめんどくさくなる
  • Native Appつくるときに認証でWebView開いて(myapp(web) -> Twitter -> myapp(web) -> myapp(native))みたいなcallbackの嵐をやるハメになる
  • Native Appのバイナリ内にサーバー側と同じConsumer Key/Consumer Secretを持つとセキュリティ上問題があるのでNativeでは持たないようにするなり、別のConsumer Keyを持つなりしないといけない
  • 更にマジメな話をするとアプリ内WebViewで外部サービスのパスワード入力させて認証させるのはfishingのおそれがあるのでアドレスバーが信頼できる外部ブラウザアプリに飛ばして認証させたほうが良い

追記

OAuthは認可であって認証ではないうんぬんの話は承知しておりますが現実としてその辺に詳しくないエンジニアの皆様は「Twitterで認証」とおっしゃられてますし、TwitterにOAuthで認可を得てverify_credential.jsonを叩いた結果からuser_idを取得するとそれは認証として問題なく使えてしまうという現実もあります(余計な権限の認可もついていますが、そっちもなんだかんだで使うし)。OAuth単独だと認証機能がないというのは事実なんですが一般的に言うOAuthとは要するにTwitterでありFacebookでありそれらのAPIと組み合わせることで認証機能を得ることができるし、IDな人たちが大好きなOpenIDの最新規格であるOpenID ConnectだってOAuthでaccess_token取得するついでにIdentityもついてくるというTwitter APIのverify_credentialを呼ぶ手間が省けて共通規格にしましたみたいなもんだし、日曜深夜にこんな長文書くハメになるのでOAuthがどうとか認証とか認可とかの議論やめたい。