便利に使わせてもらっていたのだが、NHKの番組表ウォッチサービス終了のお知らせが発表された。
終了は2019年の1月31日ということだ。不定期で年に数回あるような番組を追っかけるのには最適なサービスだったので、終わってしまうのは残念だ。これがなくなると困るので、自分で何とかすることにした。幸いなことにNHKは番組表のデータを一週間分各地域別に公開している(しかもJSON形式で)ので、このデータを取ってくれば、検索することは可能だ。そこで、かねてより興味があったのだが、実用的な使い道が思い浮かばなかったgoogle apps scriptで番組サーチできるようにしてみた。
2022年10月25日。apiのurlを修正した。
2021年3月25日。スクリプトを修正した。
- どのページでも検索できないときは、意図的にエラーが発生するようにした。
- JavaScriptランタイムv8に対応した。
2021年1月25日。スクリプトを修正した
- 番組表データ中に番組内容へのリンク、番組へのリンクがある場合があるので、それらがある場合はそれを使うようにした。
- その他微修正。
2021年1月23日。スクリプトを修正した。
2019年4月2日。スクリプトを一部修正した
GeneratePkeyLinkで生成するurlをhttpからhttpsに修正した。
2019年2月14日。スクリプトを修正した。
- リンクページのURLが変更になったので、それに合わせて修正した。
- 取得した番組データを3日分残しておくようにした。これは検索がうまくいかなかったことに後で気づいたのだが、その時の番組データが上書きされてないので、何が悪いかわからず原因を調べられなかったので、残すようにした。
googleのスプレッドシートでこんな感じでconfigという名前のシートを作り、検索キーワードを書いておく。
で、後はスクリプトエディタを起動して、
var STATIONS = { 'g1':{name:'総合',scode:'g1'},'e1':{name:'Eテレ',scode:'e1'},'e3':{name:'Eテレサブ',scode:'e3'},'s1':{name:'BS1',scode:'s1'},'s2':{name:'BS1サブ',scode:'s2'},'s3':{name:'BSプレミアム',scode:'s3'},'n1':{name:'ラジオ第一',scode:'r1'},'n2':{name:'ラジオ第二',scode:'r2'},'n3':{name:'ラジオFM',scode:'r3'} }; var DAY_OFFSET = 3; var MAX_DAY_OFFSET = 6; var NUM_OF_ROTATE = 1; var AREA = "130"; // Tokyo var I_START_TIME = 0; var I_END_TIME = I_START_TIME + 1; // 1 var I_TITLE = I_END_TIME + 1; // 2 var I_SUBTITLE = I_TITLE + 1; // 3 var I_CONTENT = I_SUBTITLE + 1; // 4 var I_ACT = I_CONTENT + 1; // 5 var I_REBROAD = I_ACT + 1; // 6 var I_LINK = I_REBROAD + 1; // 7 var N_PROGRAM = I_LINK + 1; // 8 function update () { var stations = Stations(); TiddyUpLog (); RotateSheets (stations); ReadNhkProgram (); Logger.log ("update: 読み込み終了"); StoreLog (); } function search () { var r = SearchSheets (); Logger.log ("search: " + r.length + "件検出"); if (r.length > 0) { // 検索結果をログに出力する r.forEach (function (v) { Logger.log ("serch result: " + v[0] + " " + v[1] + " " + v[2] + " " + v[4]); }); var today = new Date (); var todayString = Utilities.formatDate (today, "JST", "yyyy-MM-dd"); var html = HtmlService.createTemplateFromFile('program_mail_tmp'); html.search_result = r; var content = html.evaluate().getContent(); MailApp.sendEmail({ to: "foo@bar.jp", subject: 'NHK番組サーチ結果 ' + todayString, htmlBody: content }); Logger.log ("search: メール送信終了"); } StoreLog (); } function update_and_search () { update (); search (); } function Stations () { var stations = []; for(var item in STATIONS) { stations.push (item); } return stations; } function SearchSheets () { var keyword = ReadKeyword (); var result = []; var stations = Stations(); var countSheet = 0; var start = new Date(); Logger.log ("SearchSheets: 検索開始"); stations.forEach (function (v) { var s = SelectSheet (v); if (!s) { Logger.log ("SearchSheets: シートリードエラー" + v); } else { var cont = ReadSheet (s); if (cont.length == 0) { Logger.log ("SearchSheets: " + v + "シートは空です"); } else { var d1 = new Date (cont[0][0]); var delta = DayDiff (d1, start); if (delta < DAY_OFFSET) { Logger.log ("SearchSheets: " + v + "の日付差は" + delta + "です。更新されていません"); } else { countSheet++; var tr = SearchWord (keyword, v, cont); tr.forEach (function (v) { result.push (v); }); } } } }); var end = new Date (); var elapsed = (end - start) / 1000; Logger.log ("SearchSheets: 検索時間 " + elapsed); if (countSheet == 0) { throw new Error ("SearchSheets: 検索したシートがありません"); } return result; } function DayDiff (d1, d2) { var diff = d1.getTime() - d2.getTime(); diff = Math.floor(diff / (1000 * 60 * 60 *24)) + 1; return diff; } function FormatDate (str) { var day = new Date (str); return Utilities.formatDate (day, "JST", "yy/MM/dd HH:mm"); } function ReadKeyword () { var s = SelectSheet ('config'); var d = []; var LastRow = s.getLastRow(); var words = []; if (LastRow >= 2) { d = s.getRange (2, 1, LastRow, 3).getValues(); d.forEach (function (v) { var data = { 'word' : v[0], 'tv' : v[1]?1:0, 'radio' : v[2]?1:0 }; words.push (data); }); } return words; } function IsTv (n) { var matching = new RegExp ('^[ges][1234]'); return matching.test (n); } function IsRadio (n) { var matching = new RegExp ('^n[123]'); return matching.test (n); } function ToHankaku (str) { return str.replace(/[!-~]/g, function(s) { return String.fromCharCode(s.charCodeAt(0) - 0xFEE0); }); } function DateString (n) { var day = new Date (); if (n > MAX_DAY_OFFSET) { n = MAX_DAY_OFFSET; } day.setDate( day.getDate() + n ); return Utilities.formatDate (day, "JST", "yyyy-MM-dd"); } function ActivateSheet (name) { var ash = SpreadsheetApp.getActiveSpreadsheet(); var sheet; try { sheet = ash.getSheetByName (name); } catch (e) { sheet = null; } if (sheet == null) { try { sheet = ash.insertSheet(name); } catch (e) { Logger.log ("ActivateSheet: " + name + "シートを作成できません" + e); sheet = null; } } else { var last = sheet.getLastRow(); if (last >= 1) { sheet.getRange (1, 1, last, N_PROGRAM).clear(); } } return sheet; } function ReadNhkProgram () { var url = "https://api.nhk.or.jp/r5/pg2/list/4/" + AREA + "/all/" + DateString (DAY_OFFSET) + ".json"; const max = 3; var count = 0; var action = function () { count++; var response = UrlFetchApp.fetch(url, {muteHttpExceptions: true}); var status = response.getResponseCode(); if (status == 200) { try { var program = JSON.parse(response.getContentText()); var stations = Stations(); stations.forEach (function (v) { Logger.log ("ReadNhkProgram: 番組表書き込み " + v); var s = ActivateSheet (v); if (!s) { Logger.log ("ReadNhkProgram: シートエラー " + v); } else { SetProgramOnSheet (s, program.list[v]); } }); } catch (e) { Logger.log ("FetchStoreProgram: エラー" + e); var json_text = response.getContentText(); Logger.log (json_text); } return; } else if (count >= max) { Logger.log ("ReadNhkProgram: fetchエラー回数超過"); return; } Logger.log ("ReadNhkProgram: fetchステータス " + status); Utilities.sleep (30 * 1000); action (); // retry 30sec later }; action(); } function SetProgramOnSheet (s, cont) { var data = []; cont.forEach (function (v) { var link = ""; if (v.url) { if ('episode' in v.url) { link = v.url.episode; } else if ('short' in v.url) { link = v.url.short; } } var subdata = [ v.start_time, v.end_time, ToHankaku(v.title), ToHankaku(v.subtitle), ToHankaku(v.content), ToHankaku(v.act), v.flags.rebroad, link ]; data.push (subdata); }); if (data.length > 0) { s.getRange(1, 1, data.length, N_PROGRAM).setValues(data); SpreadsheetApp.flush(); Logger.log ("SetProgramOnSheet: " + data.length + "件書き込み"); } else { Logger.log ("SetProgramOnSheet: 更新データなし"); } } function SelectSheet (name) { var ash = SpreadsheetApp.getActiveSpreadsheet(); var sheet = null; try { sheet = ash.getSheetByName (name); } catch (e) { Logger.log ("SelectSheet: " + name + "の選択ができません"); } return sheet; } function ReadSheet (sheet) { var c = []; var LastRow = sheet.getLastRow(); if (LastRow >= 1) { c = sheet.getRange (1, 1, LastRow, N_PROGRAM).getValues(); } return c; } function SearchWord (w, s, c) { var result = []; w.forEach (function (v1) { if (IsTv(s) && v1.tv == 1 || IsRadio(s) && v1.radio == 1) { var matching = new RegExp (v1.word); c.forEach (function (v2) { if (matching.test(v2[I_TITLE]) || matching.test(v2[I_SUBTITLE]) || matching.test(v2[I_CONTENT]) || matching.test(v2[I_ACT])) { var link; if (v2[I_LINK].length > 0) { link = v2[I_LINK]; } else { link = GeneratePkeyLink (s, v2[I_START_TIME]); } var r = [ v1.word, STATIONS[s].name, FormatDate(v2[I_START_TIME]), FormatDate(v2[I_END_TIME]), encode_html_entities (v2[I_TITLE]), link, v2[I_REBROAD] ]; result.push (r); } }); } }); return result; } function GeneratePkeyLink (station, start) { var date, scode, hour, zone; scode = STATIONS[station].scode; date = new Date (start); hour = date.getHours(); if (hour <= 4) { zone = "midnight"; date.setDate (date.getDate() - 1); } else if (hour < 12) { zone = "morning"; } else if (hour < 18) { zone = "afternoon"; } else if (hour < 24) { zone = "night"; } else { zone = "all"; } return "https://www.nhk.jp/timetable/" + AREA + "/" + scode + "/" + Utilities.formatDate (date, "JST", "yyyyMMdd") + "/daily/" + zone + "/"; } function TiddyUpLog () { var today = new Date(); if (today.getDay() == 1) // clear log sheet when today is Monday { var ash = SpreadsheetApp.getActiveSpreadsheet(); var sheet; const name = "log"; try { sheet = ash.getSheetByName (name); var last = sheet.getLastRow(); sheet.getRange (1, 1, last, 1).clear(); } catch (e) { } } } function StoreLog () { var body = []; body = Logger.getLog().split (/\n/); var msg = []; body.forEach (function (v) { msg.push ([ v ]); }); var ash = SpreadsheetApp.getActiveSpreadsheet(); var sheet; const name = "log"; try { sheet = ash.getSheetByName (name); } catch (e) { sheet = null; } if (sheet == null) { try { sheet = ash.insertSheet(name); } catch (e) { Logger.log ("StoreLog: ログシートを作成できません " + e); sheet = null; } } if (sheet) { var last = sheet.getLastRow(); sheet.getRange(last+1, 1, msg.length, 1).setValues(msg); SpreadsheetApp.flush(); Logger.clear(); } else { Logger.log ("StoreLog: 書き込むシートがありません"); } } function encode_html_entities (s) { var e = s.replace (/[<>&\'\"]/gm, function (i) { return '&#' + i.charCodeAt(0)+';'; }); return e; } function doGet() { return HtmlService.createTemplateFromFile("program_html_tmp").evaluate(); } function RotateSheets (s) { if (NUM_OF_ROTATE > 1) { var ash = SpreadsheetApp.getActiveSpreadsheet(); Logger.log ("RotateSheets: 開始"); s.forEach (function (v) { for (var loop = NUM_OF_ROTATE; loop >= 1; loop--) { var oldName = v; if (loop > 1) { oldName += "." + String (loop-1); } var newName = v + "." + String (loop) try { var sheet = ash.getSheetByName (newName); if (sheet != null) { ash.deleteSheet(sheet); } } catch (e) { Logger.log ("RotateSheets: failed in deleteSheet " + oldName + " " + newName+ " " + e); } try { var sheet = ash.getSheetByName (oldName); if (sheet != null) { sheet.setName(newName); } } catch (e) { Logger.log ("RotateSheets: failed in setSheet " + oldName + " " + newName + " " + e); } } }); Logger.log ("RotateSheets: 終了"); } }
のJavaScriptのコードを適当な名前で保存し、以下のhtmlをprogram_mail_tmp.htmlファイルという名前で保存する。
<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width,initial-scale=1"> <base target="_top"> <style> table { border-collapse: collapse; width: 240px; } tr,td { padding: 1px 5px; vertical-align: top; } a { text-decoration: none; } </style> </head> <body> <h1>検索結果</h1> <table> <? var result = search_result; var keyword = []; // create keyword list from result result.forEach (function (v) { keyword.push (v[0]); }); keyword = keyword.filter(function (x, i, self) { return self.indexOf(x) === i; }); keyword.forEach (function (v1) { var submsg = ""; var bgc = 0; result.forEach (function (v2) { if (v1 == v2[0]) { var rebroad = ""; if (v2[6] == 1) { rebroad = "[再]"; } var style = ""; if ((bgc&1) == 1) { style=" style='background-color: #CCFFFF'"; } submsg += "<tr" + style + "><td>" + v2[2] + "</td><td>" + v2[1] + "</td></tr>"; submsg += "<tr" + style + "><td colspan='2'><a href='" + v2[5] + "' target=_blank>" + rebroad + v2[4] + "</a></td></tr>"; bgc++; } }); if (submsg != "") { output._ ="<tr><td colspan='2' style='background-color: #0099FF'>" + v1 + "</td></tr>"; output._ = submsg; } }); ?> </table> </body> </html>
後はこんな感じで起動のトリガーを設定すれば、この時間帯でJavaScript内のそれぞれの関数が実行されて、番組表データを取得し、検索してメールをfoo@bar.jp(これは架空のアドレス)宛に送信してくれるはず。
今の所3日後の番組表データを3時から4時の間に取得し、検索は5時から6時の間に実行し、何か見つかればメールするようにしている。3時から4時ならサーバー側の負荷で特に番組表データの読み込みエラーは発生しないという目論見なのだが、たまにHTTPのステータスが502になるようだ。HTTPステータスが200でなければ3回まで30秒待ってリトライするしているが、2回目の再取得でデータはダウンロードできているようだ。Logger.logの結果は後で参照できないようなので、logという名前のシートに保存している。ただし、シートがあふれないように月曜日の番組データ取得前に過去のログは消去するようにした。
検索結果のメールの体裁は未だに試行錯誤している。program_mail_tmp.htmlの内容次第なのだが、これでバッチリというような見た目にならないのは、デザインのセンスがないからだろう。iPhoneで見るとこんな感じ。