Nokogiri使って春アニメ録画予約の苦労を減らした
ご機嫌麗しゅう、はしぐちです。 桜がきれいですねぇ。マレーシアにいる間はこの時期に一時帰国したことがなかったので、桜が咲いてるの久しぶりに見ました。たぶん4年ぶりかな?
それはそうと、皆さん今の時期って忙しいですよね。いや、新年度だからとか別にそういうわけではなくてですね。今の時期というか、今の時期を含めて年4回は忙しいですよね。
そう、だって番組改編期ですもんね。ということはつまり、多くの深夜アニメが終わり、また多くの深夜アニメが始まる時期ということです。よって、深夜アニメの録画予約をたくさん仕込まねばなりません。
まぁ録ったところでほとんど見ないんですけどねー。見てる時間ないし。それでもネット上での評判を見たりして急に『見たい!!』と思ったときに見られないのが嫌なので、ほとんどを録ってるわけです。
さて、我が家には番組録画を行うためのサーバが2台ございまして、メイン機とスペア機となっています。たとえばあるアニメが地上波とBSの両方で放送される場合には、メイン機側で地上波放送を録画し、スペア機側でBS放送を録画する、というような具合に冗長録画体制を取るようにしています。なお、地上波かBSのどちらかでしか放送がないようなものについてはメイン機のみでの録画です。
ただこの『地上波とBSの両方で放送がある場合にどちらのサーバでどちらの放送を録画するか』のルールは、チューナー数、およびそれによる同時録画チャンネル数に制限があることだとか、これまでの歴史的経緯、運用上の制約などからもうちょっと複雑です。基本的には『地上波とBSのうち、時間的に先に放送される方をメイン機、後に放送される方をスペア機で録画する』というルールに従います。ただし、『地上波とBSが同じ日(というか同じ夜)のうちに放送される場合には、BSでの放送をメイン機で、地上波での放送をスペア機で録画する』ようにしています。こうしないとメイン機での録画対象がどうしても地上波に偏ってしまうんですよねぇ。
ってなわけで録画予約を行うだけでもこんな自分ルールがあるわけなのです。めんどくさいですねぇ。で、これまではいつもアキバ総研の新作アニメリストを見ながら、
- アニメごとに、録画対象にするかどうかを決める(というか基本的には録画対象。『これはいらんな……』というものだけ除外していく感じ。)
- 録画対象としたアニメそれぞれについて、視聴可能なチャンネルでの放送情報を抜き出す(東京都内在住なんで地上波はTOKYO MXが多いです。AT-X未加入。)
- 抜き出した放送情報をもとに、メイン機とスペア機のどちらのサーバで録画させるかを決める(これは前述のルールにしたがって決める。)
- メイン機側で録画するもの、スペア機側で録画するものそれぞれをリスト化する(予約登録が楽になるように、日付ごとに区切った上で地上波とBSそれぞれ時間順にソートします。)
- リストにしたがって、各サーバへ録画予約を手動で登録していく(EPGは1週間分しか取得できないため一度では登録しきれない。何度かに分けて登録する必要あり。)
といった作業を行っていました。結果、こんな感じのリストができあがります。
anime/2019-1Q冬アニメ/BanG_Dream_2nd_Season
BS11 2019年1月3日(木)23:30~
anime/2019-1Q冬アニメ/ブギーポップは笑わない
BS11 2019年1月4日(金)23:00~
anime/2019-1Q冬アニメ/不機嫌なモノノケ庵_續
TOKYO MX 2019年1月6日(日)22:00~
anime/2019-1Q冬アニメ/上野さんは不器用
BS11 2019年1月6日(日)24:30~
anime/2019-1Q冬アニメ/エガオノダイカ
TOKYO MX 2019年1月7日(月)22:30~
anime/2019-1Q冬アニメ/けものフレンズ2
テレビ東京 2019年1月7日(月)26:05~
(以下略)
極めてめんどくさいですねぇ。録画予約はキーワードによる自動録画なので、一度登録してしまえばそのクールの分は大丈夫です。しかしすべてのアニメが月の1週目から始まるわけでもないので、すべての録画予約を設定し終わるまで2週間とか3週間とかはかかります。
果たしてろくに見もしないデータのためにここまでの労力を割く必要があるんでしょうか。正直微妙。でもなー、もうずっとこのやり方で録画し続けてるんで、辞め時を失ってもいるんですよねー。録画してあって助かることだってやっぱり結構あるし。
で、2019春アニメの放送を前にして、『あーまたこの作業かー……』と憂鬱な気持ちになりかけたんですが、そのときふとひらめいたんですよね。『こんなもん人間がやることではない、プログラミングで解決しよう』と。
ひらめいてからは早かったです。Rubyでスクリプトを書きました。Nokogiri使ってのスクレイピングです。ruby anipota.rb
として実行すればこんな感じのリストを標準出力に吐いてくれます。
■■■ メイン ■■■
■2019/03/31(日)
anime/2019-2Q春アニメ/シーサイド荘のアクアっ娘
BS 25:30 BSフジ
■2019/04/01(月)
anime/2019-2Q春アニメ/ポプテピピック_新作テレビスペシャル
BS 24:00 BS11
■2019/04/02(火)
anime/2019-2Q春アニメ/フリージ
GR 24:00 TOKYO MX
anime/2019-2Q春アニメ/ワンパンマン
GR 25:35 テレビ東京
anime/2019-2Q春アニメ/アイドルマスター_シンデレラガールズ劇場_CLIMAX_SEASON
BS 21:54 BS11
(以下略)
スクレイピングは相当昔にPython2系+Beautiful Soupという組み合わせでちょっとだけやったことあったんですが、今回Ruby+Nokogiriでやってみて、当時よりもずーっと楽に必要なデータを取得して加工することができましたね。Nokogiriが使いやすいってこともあるとは思うんですが、ここのところずっとAtCoderをやっていて基礎力がついてきてるのがでかいっぽい。
いちおう工夫した点もいくつかありまして、
- どうせ次使うときには書いた内容なんて忘れてるはずなのでコメント多めに書いた
- アニメの放送時間に『26:00』みたいな24時超えの表記があるのをChronic使って楽に処理した
- アニメごとのさまざまなデータをそのまま配列に放り込んだら訳わからなくなりそうだったのでハッシュを多用した
- ハッシュよりも構造体使った方が良かったんだろうか?
- リスト化する際の複雑なソート(250行目〜265行目のあたり)を
sort!
一発で処理しきった。おかげでカオス。
って感じです。ただまぁコード見るとやっぱりしっちゃかめっちゃかですねぇ。はじめて触るNokogiriについては完全に手探りのトライアンドエラーだったし、しかも全体的な構成とか先に考えないまま書き始めちゃったし。それでもとりあえず作りたかったものがちゃんと作れたのでよしとしましょう。
夏アニメが始まる頃には絶対またこのコードを使うことになるはずなので、その時にまた改良するのではなかろうか。以下にGistへ上げたコードを貼り付けておきます。では。
### ここから依存パッケージ定義パート | |
# NokogiriとChronicをgemでインストールしておくように。 | |
require 'open-uri' | |
require 'nokogiri' | |
require 'date' | |
require 'time' | |
require "active_support/all" | |
require 'chronic' | |
### ここから関数定義パート | |
# アニメのタイトルを整形するための関数 format_title() を定義 | |
def format_title(string) | |
string.gsub!(/\s/, '_') | |
string.gsub!(/ /, '_') | |
string.gsub!(/\//, '/') | |
string.gsub!(/\!/, '') | |
string.gsub!(/!/, '') | |
string.gsub!(/\?/, '') | |
string.gsub!(/?/, '') | |
string.gsub!(/\&/, '&') | |
string.gsub!(/~(.+)~/, '') | |
string.gsub!(/\((.+)\)/, '') | |
string.gsub!(/-(.+)-/, '') | |
string.sub!(/__+/, '') | |
string.sub!(/_\Z/, '') | |
string | |
end | |
def format_datetime(string) | |
# 年の取得 | |
year, rest = string.split('年', 2) | |
year = year.to_i | |
# 月の取得 | |
month, rest = rest.split('月', 2) | |
month = month.to_i | |
# 日の取得 | |
day, rest = rest.split('日', 2) | |
if rest.nil? # 日付未定による分割失敗のケース | |
return Chronic.parse("#{year}-#{month}") | |
else | |
day = day.to_i | |
end | |
# 時刻の取得 | |
hour, minute = rest.gsub(/\((.+)\)/, '').split(':').map(&:to_i) | |
if minute.nil? # 時刻未定による分割失敗のケース | |
return Chronic.parse("#{year}-#{month}-#{day}") | |
else | |
return Chronic.parse("#{year}-#{month}-#{day} #{hour}:#{minute}") | |
end | |
end | |
### ここから仕込みパート | |
# 時刻はすべて日本時間で処理を行う | |
Time.zone = 'Asia/Tokyo' | |
Chronic.time_class = Time.zone | |
wd = ["日", "月", "火", "水", "木", "金", "土"] | |
# 処理の対象とするURL | |
url = "https://akiba-souken.com/anime/spring/" | |
season = "2019-2Q春アニメ" | |
charset = nil | |
# 不要なタイトルのリスト | |
no_needs = [ | |
"名作くん", | |
"ヴァンガード", | |
"ガンダム誕生秘話", | |
"KING OF PRISM", | |
"けだまのゴンじろー", | |
"少年アシベ", | |
"ダイヤのA", | |
"ちいさなプリンセス", | |
"デュエル・マスターズ", | |
"なむあみだ仏", | |
"ねこねこ日本史", | |
"猫のニャッホ", | |
"BAKUMATSUクライシス", | |
"爆丸バトルプラネット", | |
"パウ・パトロール", | |
"Bラッパーズ", | |
"MIX", | |
"ムーミン谷のなかまたち", | |
"妖怪ウォッチ", | |
] | |
# ページを読み込んでNokogiriでの処理にかける | |
html = open(url) do |page| | |
charset = page.charset | |
page.read | |
end | |
doc = Nokogiri::HTML.parse(html, nil, charset) | |
doc.search('br').each { |n| n.replace("\n") } # brタグを改行に変換 | |
### ここから情報取得パート | |
# 取得後のデータを格納する配列を用意 | |
watchable_animes = [] | |
unwatchable_animes = [] | |
unneeded_animes = [] | |
# 視聴不可と判断された放送局を格納する配列を用意 | |
channels_cannot_watch = [] | |
# ページ内のすべてのアニメを取得する | |
all_animes = doc.xpath('//div[@id="contents"]/div[contains(@class, "main")]/div[@class="itemBox"]') | |
all_animes.each.with_index do |anime, i| # アニメごとに処理 | |
# 録画対象となる放送を格納する配列 | |
available_schedules = [] | |
# タイトルの取得 | |
title = anime.xpath('div[@class="mTitle"]/h2').text | |
if title.nil? | |
puts "【エラー】タイトル取得失敗(上から#{i+1}番目)" | |
next # 次のアニメを評価 | |
end | |
# 不要アニメかどうかのチェック | |
no_need_flag = false | |
no_needs.each do |keyword| | |
if title.include?(keyword) | |
no_need_flag = true | |
break | |
end | |
end | |
if no_need_flag | |
unneeded_animes << {title: title} | |
next # 次のアニメを評価 | |
end | |
# 放送スケジュールの取得 | |
tr = anime.xpath('div[@class="itemData"]/div[@class="schedule"]/table[contains(., "放送スケジュール")]').xpath('tr') | |
if tr.size != 0 # tr == 0 ならTV放送がない(ネット配信のみ)だと判断。 | |
tr.shift # tr[0]はテーブルヘッダ部分。不要なので捨てる。 | |
tr.xpath('td').each do |td| # 放送スケジュールの各ワクごとに処理 | |
if td.xpath('span').count == 0 # ワクが空欄である場合 | |
next # 次のワクを評価 | |
elsif td.xpath('span').count != 2 # 0でも2でもないのは想定外ケース | |
puts "【エラー】タイトル: #{title}, td内のspan数が#{td.xpath('span').count}個" | |
next # 次のワクを評価 | |
else # 2である場合。これが、放送日時が記載されている場合の標準パターン。 | |
# 放送局を特定する処理 | |
station = td.xpath('span')[0].text # => "TOKYO MX" など | |
if station.empty? # 放送局が取得できない場合 | |
puts "【エラー】タイトル: #{title}, 放送局の取得に失敗" | |
next # 次のワクを評価 | |
end | |
# 特定した放送局から、地上波かBSかを特定する処理 | |
type = nil | |
# アニメ放送があると思われる、我が家で視聴可能なテレビ局のリスト。 | |
# ※NHK BS, NHK BSプレミアム, ディーライフが配列に含まれていないため要注意。 | |
tokyo_gr = ["NHK総合", "NHK Eテレ", "日本テレビ", "TBS", "フジテレビ", "テレビ朝日", "テレビ東京", "TOKYO MX"] # 地上波の放送局 | |
tokyo_bs = ["BS日テレ", "BS朝日", "BS-TBS", "BSテレ東", "BSフジ", "BS11", "BS12 トゥエルビ"] # BSの放送局 | |
if tokyo_gr.include?(station) | |
type = 'GR' # 放送局は地上波 | |
elsif tokyo_bs.include?(station) | |
type = 'BS' # 放送局はBS | |
else # 視聴できないチャンネルでの放送であると思われる。 | |
channels_cannot_watch << station # 視聴不可放送局リストに格納 | |
next # 次のワクを評価 | |
end | |
# 放送開始日時の特定 | |
temp = td.xpath('span')[1].text.gsub(/~(.*)\Z/, '') # => "2019年4月2日(火)21:54" など | |
if temp.empty? # 放送開始日時が取得できない場合 | |
puts "【エラー】タイトル: #{title}, 放送局: #{station}, 放送開始日時の取得に失敗" | |
next # 次のワクを評価 | |
else | |
start = format_datetime(temp) | |
end | |
# 録画すべき放送として配列に格納 | |
available_schedules << {type: type, station: station, start: start} | |
end # span数での分岐終了 | |
end # 放送スケジュールの各ワクごとに処理 | |
end # TV放送がある場合の処理 | |
# 取得した結果を配列に格納 | |
if available_schedules.empty? # 有効なTV放送がなかった場合 | |
unwatchable_animes << {title: title} | |
else # 有効なTV放送があった場合 | |
watchable_animes << {title: title, schedules: available_schedules} | |
end | |
end # アニメごとに処理 | |
### ここから情報吟味パート | |
# watchable_animesは配列、各項目には :title, :schedulesが格納されている。 | |
# :titleは単なるstring。 | |
# :schedulesは配列、1件以上の項目が含まれている。 | |
# 各項目は :type (String、'GR'または'BS') 、:station (String)、 :start (TimeWithZone) を持つ。 | |
main_list = [] | |
spare_list = [] | |
no_spare_list = [] | |
watchable_animes.each do |anime| | |
# :schedulesから:typeの各件数を取得し、それぞれがもし2件以上ある場合にはエラー扱いとする | |
count_gr = 0 | |
count_bs = 0 | |
gr = {} | |
bs = {} | |
anime[:schedules].each do |schedule| | |
if schedule[:type] == 'GR' | |
count_gr += 1 | |
gr = schedule | |
elsif schedule[:type] == 'BS' | |
count_bs += 1 | |
bs = schedule | |
end | |
end | |
if count_gr >= 2 || count_bs >= 2 | |
puts "【エラー】タイトル: #{anime[:title]}, 地上波放送数: #{count_gr}, BS放送数: #{count_bs}, 放送数過多" | |
next # 次のアニメを評価 | |
end | |
# 放送が1つしかない場合にはそれをメインとする | |
if anime[:schedules].size == 1 | |
main_list << {title: format_title(anime[:title]), type: anime[:schedules][0][:type], station: anime[:schedules][0][:station], start: anime[:schedules][0][:start]} | |
no_spare_list << {title: format_title(anime[:title])} | |
else # 地上波とBSとそれぞれで放送がある場合、判断が必要 | |
if bs[:start] - gr[:start] < 6*60*60 | |
# bsの方が先に放送されているか、もしくは同じ晩に放送されていそうな場合にはbs側をメインとし、gr側をスペアとする | |
main_list << {title: format_title(anime[:title]), type: bs[:type], station: bs[:station], start: bs[:start]} | |
spare_list << {title: format_title(anime[:title]), type: gr[:type], station: gr[:station], start: gr[:start]} | |
else | |
# それ以外のケースではgr側をメインとし、bs側をスペアとする | |
main_list << {title: format_title(anime[:title]), type: gr[:type], station: gr[:station], start: gr[:start]} | |
spare_list << {title: format_title(anime[:title]), type: bs[:type], station: bs[:station], start: bs[:start]} | |
end | |
end | |
end | |
# 配列をそれぞれソートしておく。 | |
# no_spare_listは単純にタイトルによるソート | |
no_spare_list.sort! { |a, b| a[:title] <=> b[:title] } | |
# main_listとspare_listは、 | |
# 1) 日毎(ただし06:00〜30:00の区切り) | |
# 2) 地上波かBSか | |
# 3) 放送開始時刻 | |
# の順でソートする。 | |
main_list.sort! { |a, b| | |
((a[:start] - 6.hour).to_date <=> (b[:start] - 6.hour).to_date).nonzero? || | |
(b[:type] <=> a[:type]).nonzero? || | |
a[:start] <=> b[:start] | |
} | |
spare_list.sort! { |a, b| | |
((a[:start] - 6.hour).to_date <=> (b[:start] - 6.hour).to_date).nonzero? || | |
(b[:type] <=> a[:type]).nonzero? || | |
a[:start] <=> b[:start] | |
} | |
### ここから結果出力パート | |
# メインの方の出力 | |
puts "■■■ メイン ■■■" | |
previous_date = Date.new(2000, 1, 1) | |
main_list.each do |e| | |
current_date = (e[:start] - 6.hour).to_date | |
if previous_date != current_date | |
head_date = current_date.strftime("%Y/%m/%d(#{wd[current_date.wday]})") | |
puts "\n■#{head_date}" | |
previous_date = current_date | |
end | |
night_hour = "" | |
if e[:start].hour < 6 | |
night_hour = (e[:start].hour + 24).to_s | |
elsif e[:start].hour < 10 | |
night_hour = "0" + (e[:start].hour).to_s | |
else | |
night_hour = (e[:start].hour).to_s | |
end | |
puts "anime/#{season}/#{e[:title]}" | |
puts "#{e[:type]} #{night_hour}:#{e[:start].strftime("%M")} #{e[:station]}" | |
puts | |
end | |
puts | |
# スペアの方の出力 | |
puts "■■■ スペア ■■■" | |
puts | |
puts "■スペアなし" | |
no_spare_list.each do |e| | |
puts e[:title] | |
end | |
puts | |
previous_date = Date.new(2000, 1, 1) | |
spare_list.each do |e| | |
current_date = (e[:start] - 6.hour).to_date | |
if previous_date != current_date | |
head_date = current_date.strftime("%Y/%m/%d(#{wd[current_date.wday]})") | |
puts "\n■#{head_date}" | |
previous_date = current_date | |
end | |
night_hour = "" | |
if e[:start].hour < 6 | |
night_hour = (e[:start].hour + 24).to_s | |
elsif e[:start].hour < 10 | |
night_hour = "0" + (e[:start].hour).to_s | |
else | |
night_hour = (e[:start].hour).to_s | |
end | |
puts "anime/#{season}/#{e[:title]}/spare" | |
puts "#{e[:type]} #{night_hour}:#{e[:start].strftime("%M")} #{e[:station]}" | |
puts | |
end | |
puts | |
### ここからダメだった分の出力パート | |
puts "■■■ 有効な放送なし ■■■" | |
unwatchable_animes.each do |anime| | |
# schedulesについて、 | |
puts anime[:title] | |
end | |
puts | |
# 最後に、channels_cannot_watchの中身をsortしてuniqして出力し、取りこぼしがないかどうかを確認しておきたい。 | |
puts "■■■ 諦めたチャンネル ■■■" | |
puts channels_cannot_watch.sort.uniq | |
puts | |
puts "■■■ 元々見る気がないヤツ ■■■" | |
unneeded_animes.each do |anime| | |
puts anime[:title] | |
end | |
puts |
記事カテゴリ: プログラミング
投稿日時: 2019/04/04(Thu)
最終更新日時: 2019/04/04(Thu)