2019/04/04(Thu)2019/04/04(Thu)

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
view raw anipota.rb hosted with ❤ by GitHub


タイトル: Nokogiri使って春アニメ録画予約の苦労を減らした
記事カテゴリ: プログラミング
投稿日時: 2019/04/04(Thu)
最終更新日時: 2019/04/04(Thu)

このブログはITエンジニアのha4guが書いています。
しばらく無職やってましたがそろそろ仕事する気になってきましたんでどこか雇ってください。 Web開発がやりたいです。勉強したぶんRailsがいいんですけどこの際あまり贅沢は言いません。 ポートフォリオはもうちょっと待って(←これをもう数ヶ月は言ってる)。
ちゃんとRailsでWeb開発やってます。JavaScriptもちょっと書きます。k8sの面倒とかも見てます。

なおこのブログはまだ構築途中です。
依然としてぜんぜんわからない。俺は雰囲気でGraphQLを書いている。

Gatsbyブログ構築 公開ToDo:
  • カテゴリ機能の実装(すべての記事がいずれかのカテゴリ1つに属すようにする)
    • 記事ページでのカテゴリ表示実装
    • カテゴリごとの記事一覧ページ実装
    • トップページでのカテゴリ一覧リンク実装
  • タグ機能の実装(記事に任意の数のタグを付与できるようにする)
    • 記事ページでのタグ表示実装
    • タグごとの記事一覧ページ実装
    • トップページでのタグ一覧リンク実装
  • 記事ごとに登録したキーワードがmetaに反映されるようにする(なってたっけ?)
  • 記事ごとにキーワードを登録する
  • Font Awesomeを使えるようにする
  • Sassを使えるようにする
  • コメント機能の検討(付けるとしたらDisqusが現実的?)
  • サイドバー実装させるかどうするか検討
  • SNS共有ボタンの実装
© 2018 ha4gu / built with Gatsby