隠居日録

隠居日録

2016年(世にいう平成28年)、発作的に会社を辞め、隠居生活に入る。日々を読書と散歩に費やす

NHKの番組表ウォッチサービスが終了するのでgoogle apps scriptで代替してみた

便利に使わせてもらっていたのだが、NHKの番組表ウォッチサービス終了のお知らせが発表された。

b.hatena.ne.jp

終了は2019年の1月31日ということだ。不定期で年に数回あるような番組を追っかけるのには最適なサービスだったので、終わってしまうのは残念だ。これがなくなると困るので、自分で何とかすることにした。幸いなことにNHKは番組表のデータを一週間分各地域別に公開している(しかもJSON形式で)ので、このデータを取ってくれば、検索することは可能だ。そこで、かねてより興味があったのだが、実用的な使い道が思い浮かばなかったgoogle apps scriptで番組サーチできるようにしてみた。
2022年10月25日。apiのurlを修正した。
2021年3月25日。スクリプトを修正した。

  • どのページでも検索できないときは、意図的にエラーが発生するようにした。
  • JavaScriptランタイムv8に対応した。

2021年1月25日。スクリプトを修正した

  • 番組表データ中に番組内容へのリンク、番組へのリンクがある場合があるので、それらがある場合はそれを使うようにした。
  • その他微修正。

2021年1月23日。スクリプトを修正した。

  • 番組内容の詳細のページがなくなったので、番組表へのリンクに変更した。
  • 使用するAPIを変更して、1リクエストで全番組を取得するようにした。
  • その他微修正。

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で見るとこんな感じ。