はじめに
我が家では10年以上前に買ったPanasonic製のHDDレコーダ Digaを愛用しております。
Digaは外出時でもブラウザでDimoraからTV番組の録画予約ができるという便利な機能があります。番組一覧から選ぶ形でUIも使いやすいです。
朝の通勤時に新聞を読む習慣がある私は、電車の中で「あ、この番組みたい」と気づくことが多く、外部からの録画予約機能を日常的に利用しておりました。
しかし10年以上経った古い製品のサポートを続けていくのはPanasonicさんもさすがにしんどいのか、2023年12月に古いHDDレコーダのサポートが切れてしまい、Dimora経由で録画予約できなくなってしまいました。(もちろん、新し目のDigaはサポートされています)。
HDDレコーダが壊れた訳ではないので新品を買うのも躊躇され、何とか外部から録画予約できないかと奮闘した結果を記事にしてみました。
同じような境遇で困られている方の一助となりましたら幸いです。
外部からの録画予約方法の検討
古いDigaで何とか外部から予約録画する方法がないかと情報を探して求めたり、取説を眺めたりしていると、「Diga Manager」というWebサーバがDiga上で動いており、ブラウザで接続すれば録画予約できることを知りました。下記のような画面です。
これは使えると思いましたが、家のネットワークはプライベートネットワークであり、外部から直接アクセスはできません。
ルーターで、DigaのIPアドレスを固定化し、Digaの80番ポートにポートフォワード設定を行うことで外部からアクセスすることは可能です。
しかしこのポートに誰でもアクセスできてしまうことになるので、セキュリティ的にも気になる所です。仮にDigaがハッキングされたとしても大した情報がある訳ではないですが、そこを起点に何かが起きるリスクも考えられなくもないです。
(外部からアクセスするには家のルーターの外向けIPアドレスを知る必要があり、Dynamic DNSなどのサービスを設定するのも少々面倒というのもあります)。
幸い、我が家ではラズベリーパイを各種制御のために常時稼働させているので、内側から外にアクセスする手がないか考えてみました。
最初はSoftEatherなどを使えないか考えてみました。AndroidやiPhoneでVPNにつなぐことはやったことがあるのでできそうな気もしますが、スマホで設定を切り替えたりとか少々面倒な気もします。
ラズベリーパイからGoogle Documentなど外部に録画番組指定情報を取りに行き、ラズベリーパイ上でブラウザを自動実行させてDiga Manager経由で録画する形も考えてみました。システムを作るのは少々面倒ですが、一度作ってしまえば使うのは楽だなと考え、この方法でシステムを作ってみました。
使うもの
- ラズベリーパイ Zero2 W(税込み3千円強)+ SDカード(16GB)
- Google App Script(GAS)(無料枠)
- Google Document
- Lineの開発者アカウント
全体の手順/処理の流れ
Diga Manager自体は番組表などのUIは持っていません。開始/終了日時、チャンネルなどを直接指定するシンプルなUIのみです。
それだと予約指定がしにくいし、指定ミスが出そうなので、やはりTV番組表から予約する枠組みは欲しいところです。TV番組表を提供しているWebサイトは複数ありますが、情報をスクレーピングしやすいシンプルな番組情報の作りをされている「TV王国」さんのサイトを利用させていただくことにしました。
手順/処理としては以下のようになります。
(1)TV王国さんのサイトをスマホのブラウザで閲覧する。
(2)録画予約したい番組があったら、番組欄をタップして、番組情報詳細を表示させる。
(3)ブラウザのメニューから「共有」を選択し、共有先として「Google Drive」を選択する。
(4)Google Driveの共有画面でTV録画用の指定フォルダ(下記例では「12_録画予約」フォルダ)にアップロードする。
→ 上記操作でGoogle Documentの指定フォルダにURLのみのプレーンテキストファイルがアップロードされるようです。
(5)ラズベリーパイ上からGoogle App Script(GAS)を呼び出し、GASのスクリプトで(4)のフォルダを見に行き、予約指定があれば、URL情報を取得する。
(6)GASのスクリプトでURL先の画面から予約に必要な情報(日時、開始時刻、終了時刻、チャンネル情報、タイトル)をスクレーピングし、その情報を返す。
(7)ラズベリーパイ上でpuppeteerを動かしてDiga Managerにアクセスし、GASから得られた情報を使って予約指定する。
(8)予約結果をGAS経由でLineでメッセージ送信する。
Google App Scriptを使った上記(5)(6)(8)の処理
予約先の番組情報のスクレイピングやLineメッセージ送信などの処理を下記手順で作ります。
(A1)Line開発者アカウントの取得とチャンネルアクセストークン、ご自分のLine IDの取得。
詳しい手順は、こちらの記事の「Line Botの作成」の項目をご参照ください。
Line IDはこちらの公式ページを参考に取得してください。
(A2)Google Driveで「新規」/「その他」/「Google App Script」を選択します。「無題のプロジェクト」は何か適当な名前をつけておきます。
(A3)メニューの「リソース」/「ライブラリ」を選択します。
(A4)表示されるダイアログの「Add a library」の右のテキストボックスに「M1lugvAXKKtUxn_vdAG9JZleS6DrsjUUV」を入力し、「追加」ボタンを押します。
(A5)「Parser」というライブラリが追加されることを確認します。
(A6)下記のコードを入力し、「ファイル」/「保存」します。(不要なゴミコードも残ってしまっています。気になる方は適宜削除してください。)
function sendLine(message) {
let CHANNEL_ACCESS_TOKEN = 'YYYYYYYYYYYYYYYYYYYYY'; // チャンネルアクセストークン
let line_endpoint = 'https://api.line.me/v2/bot/message/push';
let myId = 'ZZZZZZZZZZ'; // メッセージ送信先のご自分のLine ID
UrlFetchApp.fetch(line_endpoint, {
'headers': {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
},
'method': 'post',
'payload': JSON.stringify({
'to': myId,
'messages': [{
'type': 'text',
'text': message,
}],
}),
});
}
function getUrls() {
const folder_id = "AAAAAAAA"; // Google Documentの指定フォルダのURL(例えば、https://drive.google.com/drive/folders/AAAAAAAA)の末尾の部分を抽出
const folder = DriveApp.getFolderById(folder_id); //フォルダIDを指定
let urls = [];
const files = folder.getFiles();
while (files.hasNext()) {
const file = files.next();
const id = file.getId();
const blob = file.getBlob();
const url = blob.getDataAsString();
Logger.log( "url = " + url);
urls.push( {"id":id, "url":url});
}
return urls;
}
function getReserveInfos() {
let urls = getUrls();
let reserveInfos = [];
for (let i = 0; i < urls.length; i++) {
let url = urls[i];
let id = url.id;
let response = UrlFetchApp.fetch(url.url);
let htmlText = response.getContentText();
//Logger.log( htmlText );
let title = Parser.data(htmlText)
.from("<meta property=\"og:title\" content=\"Gガイド.テレビ王国|")
.to("\" />")
.build();
Logger.log( title );
let dateStrBase1 = Parser.data(htmlText)
.from("<dl class=\"basicTxt\">")
.to("</dl>")
.build();
//Logger.log( "dateStrBase1 = " + dateStrBase1.substring(0, 200) );
let dateStrBase2 = Parser.data(htmlText)
.from("<dl class=\"scheduleinfodetail\">")
.to("</dl>")
.build();
Logger.log( "dateStrBase2 = " + dateStrBase2.substring(0, 200) );
let dateStr = "";
let channelStr = "";
if (dateStrBase1.length < dateStrBase2.length) {
// dataStrBase1を使う
dateStr = Parser.data(dateStrBase1)
.from("<dd>")
.to("<a")
.build();
Logger.log( "dateStr1 = " + dateStr );
channelStr = Parser.data(dateStrBase1)
.from("</dd>")
.to("</dd")
.build();
Logger.log( "channelStr1 = " + channelStr );
} else {
// dataStrBase2を使う
dateStr = Parser.data(dateStrBase2)
.from("\n")
.to("<dd")
.build();
Logger.log( "dateStr2 = " + dateStr );
channelStr = Parser.data(dateStrBase2)
.from("</dd>")
.to("</dd")
.build();
Logger.log( "channelStr2 = " + channelStr );
}
channelStr = channelStr.replace("<dd>", "").replaceAll("\t", "").replaceAll("\r\n", "");
let dateTimeResult = /\s+(\d+)\/(\d+) \([^\)]+\)\s(\d+):(\d+) ~ (\d+):(\d+)/.exec(dateStr);
//let result = /\s+(\d+)\/(\d+)2 \(Mon\) (\d+)/.exec(dateTime);
console.log("dateTimeResult = " + dateTimeResult);
let month = ('00' + dateTimeResult[1]).slice(-2);
let day = ('00' + dateTimeResult[2]).slice(-2);
let startHour = ('00' + dateTimeResult[3]).slice(-2);
let startMin = ('00' + dateTimeResult[4]).slice(-2);
let endHour = ('00' + dateTimeResult[5]).slice(-2);
let endMin = ('00' + dateTimeResult[6]).slice(-2);
let jj = 0;
let reserveInfo = {};
reserveInfo.title = title;
reserveInfo.date = month + day;
reserveInfo.startTime = startHour + startMin;
reserveInfo.endTime = endHour + endMin;
reserveInfo.channel = channelStr;
reserveInfo.id = id;
reserveInfos.push(reserveInfo);
}
return reserveInfos;
}
function doGet(e) {
if (e == undefined || e.parameter == undefined || e.parameter.get != undefined) {
let reserveInfos = getReserveInfos()
let htmlText = "<html><body>" + JSON.stringify(reserveInfos) + "</body></html>";
return HtmlService.createHtmlOutput(htmlText);
} else if (e.parameter.delete_id != null && e.parameter.delete_id != "") {
// delete
var fileData = DriveApp.getFileById(e.parameter.delete_id);
var getData = fileData.setTrashed(true);
let htmlText = "<html><body>";
htmlText += "<div class='url'>delete " + e.parameter.delete_id + "</div>\n";
htmlText += "</body></html>";
return HtmlService.createHtmlOutput(htmlText);
} else if (e.parameter.message != null && e.parameter.message != "") {
sendLine(e.parameter.message);
}
}
なお、TV王国のWebページの構成が変わると上記のコードのままではうまくスクレイピング処理できないかもしれません。その場合はスクレイピング処理を適宜変更してください。
(A7)メニューの「公開」/「ウェブアプリケーションとして導入...」を選択します。
(A8)「Who has access to the app:」を「Anyone, even anonymous」にします。(URLを知っていると誰でもアクセスできるのでご注意ください。自分のアカウントの権限で実行するので、個人情報などをスクリプトで出力しないようにご注意ください)。
(A9)「Deploy」ボタンを押します。
(A10)「Authorization Requied」というダイアログがでますので、「許可を確認」ボタンを押します。
(A11)GoogleのAuth認証ダイアログがでますので、自分のアカウントを選択します。
(A12)「このアプリは確認されていません」というダイアログが出ますので、下の「xxxxxxx(安全ではないページ)に移動」のリンクを押します。(「xxxxxxxx」は(A2)のプロジェクト名)
(A13)「xxxxxxxxが Google アカウントへのアクセスをリクエストしています」というダイアログが出ますので、下の「許可ボタン」を押します。
(A14)「Current web app URL」のURLを控えておきます。これがHTML取得のURLとなります。
(A15)ブラウザで(A14)のURLをアクセスして表示されるかを確認します。
puppeteerで上記(7)の操作をする
GASで作成したHTMLページを下記手順で画像ファイル化します。
(B1)ラズベリーパイでpuppeteer-core, timersをインストールします。
2024年2月現在のpuppeteer-coreは一部処理でバグがあり、v21.1.0でないと動作しなかったです。
$ sudo apt install puppeteer-core@21.1.0
$ sudo apt install timers
(B2)キャプチャするスクリプトを作成します。
下記のスクリプトdiga.js
を作ります。
(クイックハックでとりあえず動いた状態なので、コードが整理されていないし、無駄な/変な処理も多々ありますが、ご容赦ください)
なお、スクリプト中のhttps://script.google.com/macros/s/xxxxxxx/exec
の部分はGoogle App Scriptの(A14)の公開URLに差し替えてください。
const puppeteer = require("puppeteer-core");
const { setTimeout } = require('timers/promises');
let BaseGasUrl = "https://script.google.com/macros/s/xxxxxxx/exec";
const processGASGet = async (page) => {
let url = BaseGasUrl + "?get";
try {
await page.goto(url);
} catch (e) {
await setTimeout(10000);
await page.goto(url);
}
await setTimeout(5000);
const frames = await page.frames();
const frame = frames[2];
const divs = await frame.$$('.url');
let urls = [];
if (divs != null) {
for (let i = 0; i < divs.length; i++) {
let urlInfo = await (await divs[i].getProperty('textContent')).jsonValue();
let idAndUrl = urlInfo.split(",");
urls.push({ "id": idAndUrl[1], "url": idAndUrl[0] });
}
}
return urls;
};
const processGASGetReserveInfos = async (page) => {
let url = BaseGasUrl + "?get";
try {
await page.goto(url);
} catch (e) {
await setTimeout(10000);
await page.goto(url);
}
await setTimeout(5000);
const frames = await page.frames();
const frame = frames[2];
const bodyElem = await frame.$('body');
let jsonStr = await (await bodyElem.getProperty('textContent')).jsonValue();
let reserveInfos = JSON.parse(jsonStr);
return reserveInfos;
};
const processGASDelete = async (page, id) => {
let url = BaseGasUrl + "?delete_id=" + id;
try {
await page.goto(url);
} catch (e) {
await setTimeout(10000);
await page.goto(url);
}
await setTimeout(5000);
const frames = await page.frames();
const frame = frames[2];
const divs = await frame.$$('.url');
let urls = [];
if (divs != null) {
for (let i = 0; i < divs.length; i++) {
let deleted_msg = await (await divs[i].getProperty('textContent')).jsonValue();
}
}
return urls;
};
const processGASSendLine = async (page, lineMessage) => {
let url = BaseGasUrl + "?message=" + encodeURI(lineMessage);
try {
await page.goto(url);
} catch (e) {
await setTimeout(10000);
await page.goto(url);
}
await setTimeout(5000);
};
const getReserveInfo = async (page, urlInfo) => {
let id = urlInfo.id;
let url = urlInfo.url;
// ここはお住まいの地域によって異なると思います。TV王国さんのチャンネル表示と地デジなどのチャンネル番号の組み合わせに合わせて変更ください。
const channel_list = [
{ name: "NHK総合1・京都(Ch.1)", channel: "011-0" },
{ name: "NHK総合1・大阪(Ch.3)", channel: "011-1" },
{ name: "NHKEテレ1・大阪(Ch.2)", channel: "021" },
{ name: "MBS毎日放送(Ch.4)", channel: "041" },
{ name: "KBS京都(Ch.5)", channel: "051" },
{ name: "ABCテレビ(Ch.6)", channel: "061" },
{ name: "関西テレビ(Ch.8)", channel: "081" },
{ name: "よみうりテレビ(Ch.10)", channel: "101" },
]
try {
await page.goto(url);
} catch (e) {
await setTimeout(10000);
await page.goto(url);
}
await setTimeout(5000);
const frames = await page.frames();
const frame = frames[0];
try {
await page.focus();
} catch (e) {
// 例外が発生する時はうまく扱えないページだとして諦める
console.error("give up page : " + url);
return null;
}
let basicTxt = await page.$('.basicTxt');
let titleElement = await page.$('.current');
let title = await (await titleElement.getProperty('textContent')).jsonValue();
let dds = await basicTxt.$$("dd");
let dateTimeElement;
let channelElement;
if (dds.length == 0) {
basicTxt = await page.$('.scheduleinfodetail');
dds = await basicTxt.$$("dd");
dateTimeElement = basicTxt;
channelElement = dds[1];
} else {
dateTimeElement = dds[0];
channelElement = dds[1];
}
let reserveInfo = {};
reserveInfo.title = title;
if (dds != null) {
let dateTime = await (await dateTimeElement.getProperty('textContent')).jsonValue();
let dateTimeResult = /\s+(\d+)\/(\d+) \([^\)]+\)\s(\d+):(\d+) ~ (\d+):(\d+)/.exec(dateTime);
let month = ('00' + dateTimeResult[1]).slice(-2);
let day = ('00' + dateTimeResult[2]).slice(-2);
let startHour = ('00' + dateTimeResult[3]).slice(-2);
let startMin = ('00' + dateTimeResult[4]).slice(-2);
let endHour = ('00' + dateTimeResult[5]).slice(-2);
let endMin = ('00' + dateTimeResult[6]).slice(-2);
reserveInfo.date = month + day;
reserveInfo.startTime = startHour + startMin;
reserveInfo.endTime = endHour + endMin;
let channel = await (await channelElement.getProperty('textContent')).jsonValue();
for (let l = 0; l < channel_list.length; l++) {
if (channel_list[l].name == channel) {
reserveInfo.channel = channel_list[l].channel;
break;
}
}
}
return reserveInfo;
}
const processDigaTopPage = async (page) => {
try {
await page.goto("http://192.168.0.14/"); // Digaの固定IPアドレスに変更ください
} catch (e) {
await setTimeout(60000);
if (page.url() != "http://192.168.0.14") {
await page.goto("http://192.168.0.14/");
}
}
};
const processloginPage = async (page) => {
const frames = await page.frames();
const frame = frames[1];
const text = await frames[1].content()
const passwd_input = await frame.$('input[name="passwd"]');
const password = "PPPPP"; // Digaのパスワードに変更ください
const button = await frame.$('input[name="cmd"]');
button.click();
await page.waitForNavigation();
};
const processRecorder = async (page) => {
const frames = await page.frames();
const frame = frames[1];
await setTimeout(5000);
const process_recorder_button = await frame.$('input[name="Image_Recorder"]');
try {
await process_recorder_button.click();
} catch (e) {
return false;
}
await setTimeout(5000);
return true;
};
const processAddReservationButton = async (page) => {
const frames = await page.frames();
const frame = frames[3];
const add_reservation_button = await frame.$('input[name="cCMD_RSVADD"]');
if (add_reservation_button == null) {
// エラー
console.error("can't find add_reservation_button in processAddReservationButton");
return false;
}
await add_reservation_button.click();
await setTimeout(5000);
return true;
};
const addReservationButtonExists = async (page) => {
const frames = await page.frames();
const frame = frames[3];
const add_reservation_button = await frame.$('input[name="cCMD_RSVADD"]');
return add_reservation_button != null;
};
const processSetReservation = async (page, date, startTime, endTime, channel, mode, title) => {
const frames = await page.frames();
const frame = frames[3];
const day_input = await frame.$('input[name="cRVMD"]');
if (day_input == null) {
return false;
}
await day_input.type(date);
const start_time_input = await frame.$('input[name="cRVHM1"]');
await start_time_input.type(startTime);
const end_time_input = await frame.$('input[name="cRVHM2"]');
await end_time_input.type(endTime);
const channel_input = await frame.$('input[name="cCHNUM"]');
await channel_input.evaluate(element => element.value = '');
await channel_input.type(channel);
const title_input = await frame.$('input[name="cTHEX"]');
await title_input.type(title);
const mode_select = await frame.$('select[name="cRSPD1"]');
await mode_select.select(mode);
await setTimeout(1000);
const confirm_reservation_button = await frame.$('input[name="RSV_FIX"]');
await confirm_reservation_button.click();
await setTimeout(10000);
return true;
};
const processConfirmReservation = async (page) => {
const frames = await page.frames();
const frame = frames[3];
const go_input = await frame.$('input[name="RSV_EXEC"]');
await go_input.focus();
await go_input.click();
await setTimeout(20000);
const frames2 = await page.frames();
const text2 = await frames2[3].content();
console.log("text2 3 = " + text2);
if (text2.indexOf("予約が設定できませんでした") == -1) {
return true;
} else {
return false;
}
};
(async () => {
const browser = await puppeteer.launch({
headless: true,
executablePath: "chromium-browser", // Raspberry Piはこちらを使う
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
let reserveInfoList = await processGASGetReserveInfos(page);
await processDigaTopPage(page);
try {
await processloginPage(page);
} catch (e) {
await setTimeout(60000);
if (page.url() != "http://192.168.0.14/cgi-bin/topMenu.cgi") {
await processloginPage(page);
}
}
page.setDefaultTimeout(240000);
mode = "3"; // "HL"。お好みの録画品質を設定ください
let lineMessage = "";
for (let i = 0; i < reserveInfoList.length; i++) {
if (reserveInfoList[i] == null) {
// エラーなので次の処理に移る
console.error("Error : reserveInfo is null");
continue;
}
let ret = true;
for (let j = 0; j < 10; j++) {
try {
ret = await processRecorder(page);
if (ret == true) {
console.log("processRecorder OK");
break;
}
} catch (e) {
await setTimeout(10000);
}
}
if (ret == false) {
console.error("give up");
break;
}
for (let j = 0; j < 10; j++) {
if (await addReservationButtonExists(page) == false) {
console.log("addReservationButtonExists wait " + j);
await setTimeout(10000);
} else {
break;
}
}
try {
await processAddReservationButton(page);
} catch (e) {
await setTimeout(60000);
if (await addReservationButtonExists(page) == false) {
if (await processAddReservationButton(page) == false) {
// エラーなので次の処理に移る
console.error("Error : processAddReservationButton return false.")
continue;
}
}
}
const reserveInfo = reserveInfoList[i];
if (reserveInfo == null) {
// エラーなので次の処理に移る
console.error("Error : reserveInfo is null");
lineMessage += "録画予約失敗:情報取得失敗。\n\n";
continue;
}
if (false == await processSetReservation(page, reserveInfo.date, reserveInfo.startTime, reserveInfo.endTime, reserveInfo.channel, mode, reserveInfo.title)) {
// エラーなので次の処理に移る
console.error("Error : processSetReservation return false.")
lineMessage += "録画予約失敗:" + reserveInfo.title + " " + reserveInfo.date.substring(0, 2) + "/" + reserveInfo.date.substring(2, 4) + " " + reserveInfo.startTime.substring(0, 2) + ":" + reserveInfo.startTime.substring(2, 4) + " - " + reserveInfo.endTime.substring(0, 2) + ":" + reserveInfo.endTime.substring(2, 4) + "\n\n";
continue;
}
const res = await processConfirmReservation(page);
if (res == true) {
lineMessage += "録画予約成功:" + reserveInfo.title + " " + reserveInfo.date.substring(0, 2) + "/" + reserveInfo.date.substring(2, 4) + " " + reserveInfo.startTime.substring(0, 2) + ":" + reserveInfo.startTime.substring(2, 4) + " - " + reserveInfo.endTime.substring(0, 2) + ":" + reserveInfo.endTime.substring(2, 4) + "\n\n";
} else {
lineMessage += "録画予約失敗:" + reserveInfo.title + " " + reserveInfo.date.substring(0, 2) + "/" + reserveInfo.date.substring(2, 4) + " " + reserveInfo.startTime.substring(0, 2) + ":" + reserveInfo.startTime.substring(2, 4) + " - " + reserveInfo.endTime.substring(0, 2) + ":" + reserveInfo.endTime.substring(2, 4) + "\n\n";
}
}
// Google Documentから情報を削除
for (let i = 0; i < reserveInfoList.length; i++) {
await processGASDelete(page, reserveInfoList[i].id);
}
await processGASSendLine(page, lineMessage);
await browser.close();
})();
(B3)呼び出すシェルスクリプトを作る。
スクリプトフォルダの場所は適宜変更ください。
#! /bin/bash
cd /home/pi/diga_manager
/usr/local/bin/node diga.js
(B4)上記シェルスクリプトを実行してみて動作を確認する。
(B5)うまくいったら、定期的に動作させる。
詳細はこちらの記事の「情報の自動更新設定」の項目をご参照ください。
所感
だいぶ無理やりに作りましたが、何とか外部から予約できるようになって良かったです。
TVerも充実してきたので、外部予約の必要性は薄れてきつつありますが、視聴期限もないHDDレコーダーはやはり便利です。