放送大学ダウンローダー
以下を概ね自動化したい
放送大学のインターネット授業のストリーミング配信をダウンロードする(macOS)
放送大学の字幕ファイル
CLI
puppeteerでログイン
指定した授業を一括DL
授業単位くらいがよさそう?
授業指定
回指定(なければ全15回)
字幕オプション(あれば)
FFmpeg日本語ドキュメント
作業メモ
nodeでダウンロードするところまで
前提: macにffmpegがインストールされていること(字幕用にlibassも)
code:bash
$ brew install ffmpeg --with-libass --with-openssl --with-rtmpdump
モジュールをインストール
thibauts/node-rtmpdump: A streamable wrapper around the rtmpdump CLI
fluent-ffmpeg/node-fluent-ffmpeg: A fluent API to FFMPEG (http://www.ffmpeg.org)
code:bash
$ npm install rtmpdump fluent-ffmpeg --save
rtmpdumpのsampleで試しに落としてみる
idとauthTicketを適当に入れると動いた
code:js
const rtmpdump = require('rtmpdump');
const fs = require('fs');
let id, authTicket;
const options = {
rtmp: rtmpe://vod-st.ouj.ac.jp:80/classtream?authTicket=${authTicket}token&mp4:1/${id}.mp4,
playpath: mp4:1/${id}.mp4,
swfVfy: 'https://vod.ouj.ac.jp/classtream-player/v1.2/js/video-js/5.12.6/video-js.swf',
};
const stream = rtmpdump.createStream(options);
stream.on('connected', info => console.log(info));
stream.on('progress', (kbytes, elapsed, percent) => console.log(${kbytes} kbytes read, ${elapsed} secs elapsed, ${percent}%));
stream.on('error', (err) => {
console.log(err);
process.exit(1);
});
stream.pipe(fs.createWriteStream('video.mp4'));
ffmpegでの変換が結構むずい
Error: ffmpeg exited with code 1: pipe:1: Invalid argument
pipe:1に出力してるのでだめっぽい
fs.createWriteStreamやpipe()など軒並みうまく動かない。
saveだとうまく動いた!!!!!!!!!!!
streamから直にffmpegに渡してライブトランスコーディングしてみた
コマンドラインだとffmpeg -i pipe:0 -y test.mp4
こっちのほうが早い
progress.percentはundefinedになる
code:js
const stream = rtmpdump.createStream(options);
// stream.pipe(fs.createWriteStream(${videoId}.tmp.mp4));
ffmpeg(stream)
.on('start', cmdline => {
console.log('Command line: ' + cmdline);
// Command line: ffmpeg -i pipe:0 -y test.mp4
console.log('Downloading...');
})
.on('end', () => console.log('end'))
//.on('progress', progress => console.log(Processing: ${progress.percent}% done)) //undefinedになる
.on('error', (err, stdout, stderr) => {
console.log(err);
console.log('ffmpeg stdout: ' + stdout);
console.log('ffmpeg stderr: ' + stderr);
})
.save('test.mp4')
プログレス改善
CLIでプログレスバーみたいのを出力する - Qiita
Node.jsのconsole.logとprocess.stdout.writeの違い - Tatsuya Oiwa
code:js
let duration;
// ...
.on('codecData', data => {
duration = data.duration;
})
.on('progress', progress => {
const progressPercent = Math.round((progress.timemark.slice(3, 5) / duration.slice(3, 5)) * 100)
process.stdout.write('Downloading...' + progressPercent + '%\r');
})
授業動画は知る限りだと45分しかないけどcodecDataイベントでdurationを取得できたのでそっちで計算した
分刻みの大雑把な進捗
\rはカーソルを行頭に戻すだけなので出力の桁数が前回よりも下回ると表示がおかしくなるので注意
Math.roundで小数点をなくした
メタデータを一部コピーしたい(授業名と©放送大学)
code:bash
// stream.info
{ metadata:
{ duration: 2700.02,
moovposition: 31586284,
audiocodecid: 'mp4a',
aacaot: 2,
audiosamplerate: 44100,
audiochannels: 2 },
tags:
{ cprt: '放送大学',
'©too': 'TMPGEnc',
'©nam': "心理と教育へのいざない('18)_第1回" },
trackinfo: { length: 119070720, timescale: 44100, language: 'und' },
sampledescription: { sampletype: 'mp4a' } }
https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/issues/311
.outputOptions('-c', 'copy', '-metadata', \`title=${title}\`, '-metadata', 'copyright=放送大学')
node-rtmpdumpのconnectedイベントでflvのタイトルは取得できるが、ffmpegに同期的に渡せなさそう
ダウンロードリストを作るところ
結構メモ残してたのにscrapbox保存されてなくて泣いた😭
APIである程度情報取得ができたhttps://vod.ouj.ac.jp/v1/tenants/1/vod-contents/
要ログインセッション
curl -s -b ClsSID=xxx https://vod.ouj.ac.jp/v1/tenants/1/vod-contents/
https://vod.ouj.ac.jp/v1/tenants/1/vod-contents/count?q=検索ワード&qt=4
qt=4をつけないとフルで取得できない。なんだろう。
数字が小さいほど取得できる数が少ない
code:json
[
{
"createdDate": "2017-02-15T16:07:25+0900",
"updatedDate": "2017-03-28T18:22:21+0900",
"contentId": 873,
"categoryId": 397,
"title": "第01回 学校図書館メディアの意義と役割",
"summary": "知識情報社会における学校と学校図書館について、その学習環境の変化とそこでの学校図書館メディアの現状を取り上げる。さらに、教育課程の変化と学校図書館メディアの意義について講義する。",
"detail": "学校図書館メディアの構成(’16)\n北 克一、平井 尊士\n北 克一,米谷 優子\n",
"point": 0,
"spotPrice": 0,
"publishedOpen": false,
"alias": "1",
"pickup": false,
"fileType": "mp4",
"popUpTime": 0,
"showPopUp": false,
"viewingCount": 1055,
"contentType": "V",
"tenantId": 1,
"groupIds": [
2
],
"duration": 2700
},...
内容
createdDate, updateDate: そのまんま
contentId: 動画の個別ID
categoryId: 科目ID
シラバスとは一致しない
https://vod.ouj.ac.jp/v1/tenants/1/categoriesで一覧が見れる
title: 授業のテーマ
第n回 以降はシラバスのテーマと一致する
summary: 内容
シラバスの内容と一致する
detail: 科目名と担当講師
{科目名}\n{主任講師}\n{授業担当講師},{授業担当講師}... ゲスト :{ゲスト}\n
alias: 授業の回の数字と一致
duration: 動画の長さ(秒)
ほかは使われていない
contentTypeはテレビとラジオで分けられるかと思ったけど、ラジオもフラッシュで動画なので全部Vなのだと思われる
シラバスの科目ID、ナンバリングとは一致しない
学部、コースなどの情報は載ってない
https://vod.ouj.ac.jp/v1/tenants/1/vod-contents/?ca={categoryId}のページの要素から授業の階層を取得できる
code:html
<div class="breadcrumbs">
<ul>
<span role="heading" aria-label="カテゴリーの現在位置"></span>
<!--template bindings={}-->
<li tappable="">
<a href="javascript:void(0);">
01 教養学部
</a>
</li>
<li tappable="">
<a href="javascript:void(0);">
06 人間と文化コース
</a>
</li>
<li tappable="">
<a href="javascript:void(0);">
44 比較認知科学(’17) 1529188a
</a>
</li>
</ul>
</div>
1529188は科目コード
aは別コースにある同じ授業があるときにつくみたい(心理と教育の方はaがなかった)
detailに変なのが混じってるのがある。(追記: ラジオ番組の字幕付加実験カテゴリ)
この字幕テキストは、京都大学 学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。
<TEXTFORMAT LEADING="2"><P ALIGN="LEFT"><FONT FACE="メイリオ,Meiryo UI,Meiryo,ヒラギノ角ゴ Pro W3,Hiragino Kaku Gothic Pro,Osa" SIZE="12" COLOR="#006600" LETTERSPACING="0" KERNING="0">この字幕<FONT KERNING="1">テキストは、京都大学 </FONT>学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。</FONT></P></TEXTFORMAT>
<TEXTFORMAT LEADING="2"><P ALIGN="LEFT"><FONT FACE="メイリオ,Meiryo UI,Meiryo,ヒラギノ角ゴ Pro W3,Hiragino Kaku Gothic Pro,Osa" SIZE="12" COLOR="#006600" LETTERSPACING="0" KERNING="0">この字幕テキストは、京都大学 学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。</FONT></P></TEXTFORMAT>
<TEXTFORMAT LEADING="2"><P ALIGN="LEFT"><FONT FACE="メイリオ,Meiryo UI,Meiryo,ヒラギノ角ゴ Pro W3,Hiragino Kaku Gothic Pro,Osa" SIZE="12" COLOR="#006600" LETTERSPACING="0" KERNING="0">この字幕テキストは、京都大学 学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。<FONT COLOR="#000000"></FONT></FONT></P></TEXTFORMAT>
<TEXTFORMAT LEADING="2"><P ALIGN="LEFT"><FONT FACE="メイリオ,Meiryo UI,Meiryo,ヒラギノ角ゴ Pro W3,Hiragino Kaku Gothic Pro,Osa" SIZE="12" COLOR="#006600" LETTERSPACING="0" KERNING="1">この字幕テキストは、京都大学 学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。<FONT FACE="メイリオ" KERNING="0"></FONT></FONT></P></TEXTFORMAT>
<TEXTFORMAT LEADING="2"><P ALIGN="LEFT"><FONT FACE="メイリオ,Meiryo UI,Meiryo,ヒラギノ角ゴ Pro W3,Hiragino Kaku Gothic Pro,Osa" SIZE="12" COLOR="#006600" LETTERSPACING="0" KERNING="1">この字幕テキストは、京都大学 学術情報メディアセンター河原研究室と本学教育支援センター障害者支援プロジェクトが協力して、自動音声認識技術を用いて作成されました。</FONT></P></TEXTFORMAT>
カテゴリーのAPI発見!勝利!https://vod.ouj.ac.jp/v1/tenants/1/categories
code:json
...
{
"createdDate": "2018-03-23T15:33:06+0900",
"updatedDate": "2018-04-01T07:53:15+0900",
"categoryId": 879,
"parentId": 7,
"name": "11 身近な統計(’18) 1160010",
"summary": "(テレビ) 石崎 克也、渡辺 美智子",
"alias": "1160010",
"tenantId": 1
},
...
parentIdをさかのぼっていくことで階層がわかる
0でそれ以上上がない
categoryId: 29までは学部・コースなどのカテゴリーで以降は各科目
必要なAPI揃ったのでpuppeteerなしでも行けるかと思ったけどHTTP clientでredirectチェーンを追っていくのが案外難しい
とりあえずpuppeteerでそのままやる
https://stackoverflow.com/questions/48511357/how-to-scrape-json-from-puppeteer
CLIで対話的に検索を絞りたい
inquirer.js
とりあえずプラン
引数付きで直にダウンロード(commander)
引数なしで対話モードで起動
検索
カテゴリー選択
学部・大学院・etc...
コース
科目
確認(noなら再帰呼出し)
ダウンロードする回(チェックリスト)
再帰関数完全に理解した
ダウンロードは科目単位で良さげ
1回につき最大15ダウンロードなので多いあc
別案で今期受講している科目を引っ張ってきて一括ダウンロードも良いかと思ったけど、個人ページに飛ぶ必要があり、あんまりクレデンシャルに触りたくない
まぁ、CLIで成績とか色々表示できるのも面白そう
contetIdは順序通りではない(ほぼ全てだと思われる)
code:json
{ name: '第09回 転換期における教育', value: 3231 },
{ name: '第10回 子どもの育ちと生成としての教育', value: 3232 },
{ name: '第11回 教育の構造と機能', value: 3233 },
{ name: '第12回 教育の文化的基礎', value: 3234 },
{ name: '第13回 教育学の系譜(1)-社会現象としての教育-', value: 3235 },
{ name: '第14回 教育学の系譜(2)-現代教育学の流れ-', value: 3236 },
{ name: '第15回 学習社会の成立と生涯学習', value: 3237 },
{ name: '第01回 教育を科学する~教育とは何か~', value: 3238 },
{ name: '第02回 教育にとって家族の果たす役割', value: 3239 },
{ name: '第03回 教育環境としての地域社会', value: 3240 },
{ name: '第04回 近代社会の成立と学校', value: 3241 },
{ name: '第05回 公教育制度の展開とゆらぎ', value: 3242 },
{ name: '第06回 学校の組織と文化', value: 3243 },
{ name: '第07回 教育内容と教育方法', value: 3244 },
{ name: '第08回 生徒指導と道徳教育', value: 3245 }
aliasでsortする
Array.prototype.sort() - JavaScript | MDN
数字でソートする場合はa - b
https://vod.ouj.ac.jp/v1/tenants/1/vod-contents/${contentId}/video-src?hls=falseからauthTicketを取得する
ちなみにhls=falseを取るとHLSのURLが出てくる。iPhone端末とかからだとauthTicketが付与されてdumpできる?
forEachで回そうとしたらエラー
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 Symbol(Connection.Events.Disconnected) listeners added. Use emitter.setMaxListeners() to increase limit
EventEmitter memory leak detected. 11 exit listeners added #594
Event Emitterはお勉強しよう。
処理ごとにブラウザを閉じてCookieを渡せばいける?
setCookieがうまくできない
UnhandledPromiseRejectionWarning: Error: Protocol error (Network.deleteCookies): Invalid parameters name: string value expected
ドキュメント読み間違えてた。スプレッド演算子で渡してあげればworkした
荒業でmaxを増やすなどrequire('events').EventEmitter.defaultMaxListeners = 15;
根本解決になってないのでやらないほうがよさそう
vod-contentsから取得した各動画のオブジェクトにauthTicketとexistsSamiFileをassignして返すことにした
code:json
{ createdDate: '2017-02-15T19:09:05+0900',
updatedDate: '2017-03-28T18:39:05+0900',
contentId: 3372,
categoryId: 31,
title: '第01回 市民自治とは何か',
summary: '「市民自治」という概念のイメージをつかむ。松下圭一の市民自治論を概観し、その批判的意義を理解する。そのうえで「市民社会」の概念を類型的に考察し、「市民」概念の理解を深める。日本における「自治」概念の特徴と問題性を検討し、自治的発想への批判を克服できる視点を求める。そのために、「自治」と「統治」をあらためて比較し、市民自治概念を批判的、反省的に理解すべき理由を確認する。',
detail: '市民自治の知識と実践(’15)\n山岡 龍一,岡﨑 晴輝\n山岡 龍一,岡﨑 晴輝\n',
point: 0,
spotPrice: 0,
publishedOpen: false,
alias: '1',
pickup: false,
fileType: 'mp4',
popUpTime: 0,
showPopUp: false,
viewingCount: 3585,
contentType: 'V',
tenantId: 1,
groupIds: 2 ,
duration: 2700,
authTicket: hoge,
existsSamiFile: false }
コンソールに色つけたい
コンソールに出力する文字に色をつける - Qiita
Confirmを関数化した
noを選択したときに再帰関数を呼び出す
検索は大丈夫だが、カテゴリー選択の方はwhileループが回らない
というか再帰使うんなら再帰の中でwhile使う必要ないのでは
別にwhileが特殊な振る舞いをするわけではないっぽい
confirm関数でcategoryIdを初期化してなかったからだった
no選択時にトップに戻るようにしたほうがよさげ
ログイン処理のカプセル化
how to clear input field value before typing/inserting?
ログインIDとパスワードの管理面倒くさい
ID・パスワードは.credentialsに保存できるようにした
ローカルにjsonを置いておく方法
ouj init
初期化
ログイン情報の保存
vod-contents、categoriesなどの各種APIの保存
ouj login
ログイン情報更新
ouj update
API更新
何がいいか
ログインとAPI取得の重さが減る
カテゴリー選別まではローカルで、実際にアクセスするのはcontentId取得時のみ
APIの変更は学期ごとなので都度手動でSyncしてもらったほうがよいかも
なにはともあれ一回ダウンロード完了するところまで完成させる
ffmpegのダウンロードは並列進行可能
progressの表示ができない
全体の進捗でもできればいいのだが。。。
エスケープシーケンス使っても難しそう?
並列ダウンロードしても速度あんまかわらなそうなので一個ずつにする
endイベントでdownloaderを再帰呼び出しする
できた
Puppeteerはログインしてクッキー取得だけにしてあとはrequestでできないか?
redirect地獄なのでredirectだとcookieの取得が難しそう
メタデータ色々追加してもよい?
Adding Meta Data to MP4 Video | Kdenlive
artistに教授、albumに科目名、album_aritstに放送大学、commentにsummary、episode_idにaliasを入れた
字幕の選択肢
科目ごと
選択肢(チェックリストがいいかな)
字幕(srt)をダウンロードする(プレイヤー用)
字幕を取得し、動画に焼く
字幕を取得しない
Streamコピーしながら字幕フィルタをつけるのは無理っぽい
Filtering and streamcopy cannot be used together.
ラジオ番組の字幕付加実験
detailに駄文がある問題
科目名と担当講師が取得できない
一貫性崩してまで障害者プロジェクトでやりました!って書くのすごい健常者っぽい
取得時にvodContents.jsonを書き換えるのがよさそう
aliasに入っている科目コードのあとにsがつく
タイトルは変わっている場合があるのでaliasで判定したほうがよさげ
ラジオ番組の付加実験カテゴリー
code:json
{
"createdDate":"2017-02-10T19:31:43+0900",
"updatedDate":"2017-02-10T19:31:43+0900",
"categoryId":5,
"parentId":0,
"name":"04 ラジオ番組の字幕付加実験",
"summary":"","alias":"","tenantId":1
},
{
"createdDate":"2017-02-10T19:31:43+0900",
"updatedDate":"2017-02-10T19:31:43+0900",
"categoryId":25,
"parentId":5,
"name":"01 教養学部",
"summary":"",
"alias":"",
"tenantId":1
},
{
"createdDate":"2017-02-10T19:31:43+0900",
"updatedDate":"2017-02-10T19:31:43+0900",
"categoryId":26,
"parentId":5,
"name":"02 大学院",
"summary":"",
"alias":"",
"tenantId":1
},
{
"createdDate":"2017-02-10T19:31:43+0900",
"updatedDate":"2017-02-10T19:31:43+0900",
"categoryId":27,
"parentId":5,
"name":"03 特別講義",
"summary":"",
"alias":"",
"tenantId":1
}
code:js
// ラジオ番組の字幕付加実験のサブカテゴリーを取得
const experimentCategories = categories
.filter(category => category.parentId === 5)
.map(category => category.categoryId);
const experimentSubjects = categories
.filter(category => experimentCategories.indexOf(category.parentId) > -1)
.map(category => ({
categoryId: category.categoryId,
originalCategoryId: ((category) => {
return categories
.filter(originalCategory => originalCategory.alias === category.alias.slice(0, -1))
.map(category => category.categoryId)0;
})(category)
}));
console.log(experimentSubjects);
//[ { categoryId: 407, originalCategoryId: 69 },
// { categoryId: 408, originalCategoryId: 133 },
// { categoryId: 409, originalCategoryId: 262 },
// { categoryId: 802, originalCategoryId: 159 },
// { categoryId: 410, originalCategoryId: 383 },
// { categoryId: 800, originalCategoryId: 380 },
// { categoryId: 993, originalCategoryId: 981 },
// { categoryId: 411, originalCategoryId: 447 },
// { categoryId: 801, originalCategoryId: 791 } ]
特別講義はdetailに科目名が含まれない!
こっちも書き換えるか…
というか、もう最初からvod-contentsにcategoriesの情報を付与すればいいのでは
科目名はそれでクリアできる
問題は授業ごとの講師名
categoryには主任講師しか載らない
各回別々の講師が担当する授業も多い
これはdetailにしかない情報
ここをクリアするために結局各授業から拾ってくることになる
まぁでも配列よりも科目をkeyにしたオブジェクトにしたほうが色々楽そう
code:json
...
879: {
"createdDate": "2018-03-23T15:33:06+0900",
"updatedDate": "2018-04-01T07:53:15+0900",
"categoryId": 879,
"parentId": 7,
"name": "11 身近な統計(’18) 1160010",
"summary": "(テレビ) 石崎 克也、渡辺 美智子",
"seniorProfessors": "石崎 克也","渡辺 美智子"
"alias": "1160010",
"tenantId": 1,
"vodContents": {
1: {
(vod-contents.jsonの各prop),
"subject": "身近な統計(’18)",
"personInCharge": "石崎 克也", "渡辺 美智子"
},
2: {subject2},
...
}
},
...
通常の講義はaliasに回が入るが、特別講義は科目IDが入る
特別講義は1回45分のみ。(多分)
parentIdが"27":"特別講義","28":"テレビ","29":"ラジオ"の場合は例外処理
詳しくはこうだった(29のラジオの字幕付加版が27特別講義)
code:json
"5(ラジオ字幕の付加実験)" : {
25: "教養学部",
26: "大学院",
27: "特別講義"
},
"6(特別講義等)" : {
28: "テレビ",
29: "ラジオ"
}
vod-contents.jsonはparentId持ってなかった
25, 26, 27→detail欠損
28, 29 → detailに授業タイトルなし(授業名=科目名)
aliasが2以上の場合は例外処理
aliasの数字は文字列
うまくいった
コマンドいくつか作った
login: ログイン情報を更新する
update: APIを更新する
reset: ログイン情報とAPIを消去(初期化)
それぞれ個別に消せてもいいかと思ったけど個別ならupdateでいいかと。
とりあえず公開した
https://www.npmjs.com/package/ouj-downloader
依存でChromiumがまるまるダウンロードされるのはキツそう
puppeteer-coreを使えばよさそう?
https://github.com/GoogleChrome/puppeteer/issues/3157
You'll have to explicitly specify executablePath option in the puppeteer.launch; alternatively, you can connect to a running instance with puppeteer.connect
今回の学び
参照元のデータの正規性が疑わしい場合は最初に正規化をやる
後回しにするとデータに振り回される
プロパティに格納する値、区切り文字、ルールなどが全く統一されていないことがある
#放送大学 #作業メモ