テキストをスクロール表示させる

プログラムについて

諸兄も一度はYouTubveで見たことがあるのではないだろうか。

文字がただ流れるだけの動画を。

自分のペースで読めない上に内容に乏しく、非常にイライラするものだが、 作業ウィンドウ以外で流しておくことでウィンドウフォーカスを切り替えなくて済む、というメリットもある。

特にmpvを使うと、右クリックで再生/停止ができるし、再生速度もコントロールできてまあまあ便利だ。

ただ、検索性がないのでやっぱりストレスである。

そこで、テキストリーダーに自動スクロール機能が欲しい、と思った。

ありそうだが、見当たらなかった。

もちろん、一行単位であればsleepreadでも組み合わせればいいのだが、これはパッと動いてしまうため目の追従が難しく、読みづらい。 1ピクセルずつ動かしたいのだ。

xdotoolを使う、という方法も考えたのだが、意図したようにはうまくいかなかった。

JavaScriptを使えばすごく簡単なのに…というわけで、JavaScriptを使う方向でがんばることにした。

すごーーーーく単純に一時ファイルでHTMLを作ってQtWebengineで表示すればいいと思って、Pythonで作ったのだが…

…Surfでよかった。

まぁ、配布するならSurfみたいなマイナーなものは入れたくないなんていう天邪鬼さん1や、Surfは入っていないなんていう弱小ディストリさんはいっぱいいると思うので良しとしよう。

そんなわけで作った。

珍しく日本語READMEもある。 BGM機能があるのは、YouTubeでよく見るものをリスペクトしたものであり、背景画像がほしければCSS編集でOKだ。

やっていることはごく単純で、Pandocで処理することを前提として一時ファイルを活用している。

プレーンテキストの場合はsedを使ってラインブロック化している。 この場合改行されなくなってしまうので、pre-wrapするようにデフォルトのスタイルシートで変更している。

スクロール制御はJavaScriptでものすごく入門的なコードだ。 右クリックでも再生・停止できるようにしているのは、コントロールをウィンドウフォーカスしてからでなくても行えるようにするためである。

ちなみに、今回のJavaScriptは互換性をあまり気にしない贅沢なコードでもある。 これはかなり新しいGtkWebkit、あるいはQtWebengineを使うという前提が成り立っているためだ。

解説

それでは恒例の初心者向けコード

JavaScript部分

せっかくなので、入門的なJavaScriptコードを解説しよう。

スクロール部分

まずスクロール自体はwindow.scroolByによって実現できる。

自動スクロールをするためにはこれを繰り返すようにしなくてはいけない。 もちろん、単純なループでもできるのだが、JavaScriptはシングルスレッドなので操作不能になってしまう。

このような反復はJavaScriptではタイマーイベントで行う。 タイマーにコールバック関数を登録することで、タイマー起動時にコールバック関数が実行される。 もし他の処理が実行中の場合は処理をキューに入れ、順番がきたら実行される。

反復の場合setIntervalのほうが簡単で、初心者向けの解説ではよくこちらが使われるが、 どちらかといえばタイマーイベントを都度登録するsetTimeoutのほうがコントロールしやすい。

関数オブジェクト、クロージャという考え方になれていないとそもそもコールバック関数が使えないので、ここはしっかりと理解しておく必要がある。

関数オブジェクトは実行可能なコード群をオブジェクト化したものである。 スイッチを押せば実行される物体になっているとでも思えばいい。 コールバック関数は、それを呼び出すタイミングでそのスイッチを押すように動作する。

setTimeout は指定した時間が経過したときにコールバックを行う。 コールバック関数の中で自身をコールバック関数とする setTimeout を呼ぶことでループさせることができる。

「スクロールの開始」はその関数を呼べば良い。 setTimeout で呼ばなくても一度呼べば setTimeout によってループする。

「スクロールの停止」は setTimeout をしなければ次回の実行がなされないため、停止する。 停止方法はタイマーをキャンセルするのではなく、次回のタイマーセットを行わないというものである。 このため、タイマー動作状態がonの場合のみ setTimeout を行う。

setInterval を使用した場合はタイマーをセット/キャンセルして制御する。

キーイベント部分

キーボードのキー入力は document あるいは window に対してイベントリスナーを設定する。

DOM Level1 のonKeydown を設定する場合の情報は割とあるのだが、 DOM Level3 の addEventListener を設定する情報はやや少ない。

コールバック関数の引数としては KeyboardEvent オブジェクトが渡される。 特定のキーをキャプチャするわけではなく、キーボード入力全てをキャプチャしてコールバック関数が呼び出される。 コールバック関数内でキーを判別する。

なお、コールバック関数が時間がかかるとフリーズしていると感じることになるため注意が必要。

キーの種別を取るための方法は色々あるのだが、 code が推奨される。 プリンタブルなキーに限定するのであれば key でも良い。 charcharCode はあまりうまく動作しないし、 keyCodewhich は廃止されている上に、プラットフォームに依存する。

それぞれどのような値になるのかは次のようなコードを書いて確認すれば良い。

元のキーの動作を無効にする場合は、第三引数を false として先にキーを捕捉してから Event.preventDefault によってバブリングを停止する。

右クリック禁止2、でお馴染み右クリックは contextmenu イベントになる3

スクロールスピード

スクロールは

  • スクロールする時間間隔が短くなると速くなる
  • 一度にスクロールする量が増えると速くなる

量が増えるとスムーズさが書けるため、時間を短くするほうが優先。 間隔が1(1ミリ秒)になった場合、加速はスクロール量によって行う。

逆に遅くするときはスクロール量が増えているならそちらを先に減らす。スクロール量が1であれば時間間隔を増やす。

1ミリ秒刻みでは使いにくかろうと思ったので、5ミリ秒刻み、ただし値が小さいほうが変化量は大きいため、5ミリ秒からは1ミリ秒で調整されるようにしている。 (50ミリ秒から+5された場合は10%遅くなるが、10ミリ秒から-5された場合は100%速くなる)

割合で増減させてもいいのだが、どちらかといえばキーリピートが効くため一定間隔にしたほうが良いUIであると考えられる。

今回は50ミリ秒をデフォルトとしたため、10%の5ミリ秒を増減単位とした。 なお、この速度感は画面のピクセル数によって異なり、特にピクセル数が多く高精細なディスプレイの場合は遅く、ディスプレイサイズが大きくピクセル数が少ない場合は速く感じることになる。

設計

基本的には「得意なことは得意な方法で」だ。

このようにスクロールやキーイベントなどはウェブブラウザとJavaScriptが簡単に書ける。 だから無理せずウェブブラウザとJavaScriptで書こうと考えたわけだ。

ただし余計な機能や情報があると使いにくい。 最低限のウェブブラウザが欲しいのだが、既にそのようなものはSurfがあるのでこれを使う。

もっとも、レンダリング部分はQtwebengineやGtkWebkitがあるのだから書くのは非常に簡単である。

もちろん、そのためにはテキストをHTMLにする必要がある。

テキストを正確にHTMLで表現するのはちょっと大変だが、CSSで white-space: pre-wrap にしてしまえばタグ要素と & をエスケープしてしまえば元のテキストを表現できる。 このようなことはSedでもできる。 ただし、 & を先にエスケープしなくてはならない。エスケープがエスケープされることを防ぐためだ。

テキスト処理するためのツールはLinux上に豊富にある。このようなことはシェルが得意とする部分だ。 文字列を埋め込むだけならば特別なツール(例えばeRuby)を使わなくても、ヒアドキュメントで十分だろう。 プログラムはシェルスクリプトで構築する、という方針は簡単に決められる。

中間的なファイルや生成に必要なファイルは、もちろん予め用意してリンクさせることもできるが、ディレクトリ設計に関する障害を増やすことになる。 今回はローカルディレクトリにインストールする、という前提を与えてはいるが、それでもできれば避けたいところだ。 それにバージョンアップの手間も考えれば、一時ファイルを作る方針にした。 これも簡単なものなのでヒアドキュメントで処理する。

ブラウザを作るにあたっては、私の得意なRubyではなく、割と苦手なPythonにした。 Rubyにもqml Rubyバインディングは存在するし、動作もするが、メンテナンスされておらず(3年間放置されている)、情報も非常に少ない。 また、ruby-qmlよりもpyqtのほうがqml自体もシンプルに書けるので、Pythonを採用した。

表示はウェブブラウザ(作ったもの的にはPython+qml+Webengine)で、スクロールはJavaScriptで、変換と橋渡しはシェルスクリプトで。 コンポーネントを分けて、それぞれが得意なことをシンプルに行う。 問題を簡単にし、ミスを減らすポイントでもある。


  1. もっとも、Unsurfを動かすためにはPyQt5とpython-openglが必要なので、ハードルの高さはどちらが上やら

  2. だいぶ懐かしい響きだが、今でもしているところはある。あまり意味はない。

  3. より正確にいえば、これはコンテキストメニューを表示させたときに発生するイベントで、右クリックと一対一ではない。キーボードのメニューキーを押した場合や、右クリック以外をコンテキストメニューキーにしている場合も同様にも発生するし、右クリックがコンテキストメニューでないのならば発生しない。

PureBuilder Simplyのアップデート (ReST完全対応)

3ヶ月ぶりとなったPureBuilder Simplyのアップデート。

今回はMimir Yokohamaのウェブサイトの新連載(そもそも連載開始にお金かかるので時期未定)でReSTructured Textを使うため、ReSTに完全対応した。

完全対応のポイントは、従来きちんと対応できていなかったdocinfo(Bibliographic Elements)に対応するようにした。

ただし、これは「対応した」という言い方が適切なのかどうかわからない。 ひとつは、自力でdocinfoを解釈する部分が間違っていたのと、仕様の理解自体正しくなかったので適正にした。

File.open([dir, filename].join("/")) do |f|
    l = f.gets
-        if l =~ /:[A-Za-z]+: .*/ #docinfo
-          docinfo_lines = [l.chomp]
+        if l =~ /:([A-Za-z]+): (.*)/ #docinfo
+          frontmatter = { $1 => [$2.chomp] }
+          last_key = $1

        # Read docinfo
        while(l = f.gets)
            break if l =~ /^\s*$/ # End of docinfo
-            if l =~ /^\s+- / && (docinfo_lines.last.kind_of?(Array) || docinfo_lines.last =~ /^:.*?: +-/) # List items
-              if docinfo_lines.last.kind_of?(String)
-                docinfo_lines.last =~ /^:(.*?): +- *(.*)/
-                docinfo_lines[-1] = [ [$1, $2] ]
-              end
-              docinfo_lines.last[1].push(l.sub(/^\s+- +/).chomp)
-            elsif l =~ /^\s+/ # Continuous line
-              docinfo_lines.last << " " + $'.chomp
-            elsif l =~ /^:.*?: +.*/
-              docinfo_lines.push l.chomp
+            if l =~ /^\s+/ # Continuous line
+              docinfo_lines.last.push($'.chomp)
+            elsif l =~ /:([A-Za-z]+): (.*)/
+              frontmatter[$1] = [$2.chomp]
+              last_key = $1
            end
        end

-          # Convert Hash.
-          frontmatter = {}
-          docinfo_lines.each do |i|
-            if i.kind_of?(Array) #list
-              # Array element
-              frontmatter[i[0]] = i[1]
-            elsif i =~ /^:author: .*[,;]/ #author
-              # It work only pandoc style author (not Authors.)
-              author = i.sub(/:author: /, "")
-              if author.include?(";")
-                author = author.split(/ *; */)
-              elsif author.include?(",")
-                author = author.split(/ *, */)
-              end
+          # Treat docinfo lines
+          frontmatter.each do |k,v|
+            v = v.join(" ")
+            if((k == "author" || k == "authors") && v.include?(";")) # Multiple authors.
+              v = v.split(/\s*;\s*/)

-              frontmatter["author"] = author
-            elsif i =~ /^:(.*?): +(\d{4}-\d{2}-\d{2}[T ]\d{2}[0-9: T+-]*)$/ #datetime
-              key = $1
-              time = DateTime.parse($2)
-              frontmatter[key] = time
-            elsif i =~ /^:(.*?): +(\d{4}-\d{2}-\d{2}) *$/ #date
-              key = $1
-              time = Date.parse($2)
-              frontmatter[key] = time
-            elsif i =~ /^:(.*?): +/
-              key = $1
-              value = $'
-              frontmatter[key] = value
+            elsif k == "date" # Date?
+              # Datetime?
+              if v =~ /[0-2][0-9]:[0-6][0-9]/
+                v = DateTime.parse(v)
+              else
+                v = Date.parse(v)
+              end
+            else # Simple String.
+              nil # keep v
            end
+
+            frontmatter[k] = v
        end

    elsif l && l.chomp == ".." #YAML
        # Load ReST YAML that document begins comment and block is yaml.
+          @extra_meta_format = true # ReST + YAML is not supported by Pandoc.
        lines = []

        while(l = f.gets)

考え方自体に大きな変更があったため、変更行数も多い。 ただし、22165f2よりも8f6e453のほうが以前のバージョンとの違いは少なくなっている。

ここでひとつポイントだ。ReSTの仕様上、authorsa,b,cと書かれた場合は3人の著者になる。a,b,c;と書かれた場合は1人の著者になる。 だが、Pandocは;でのみ分割するので、この仕様に従っている。このため非常にシンプルな仕様だ。

基本的にdocinfoの場合、authorsdateのみが特別扱いされる。 Pandoc的にもそのような仕様になっており、authorsauthorの代わりに書ける点も正式な仕様に従っている。 PureBuilderもこれにならって、それ以外のフィールドについては特別扱いしない。 また、Pandocは仕様にない項目を入れてもエラーにしないため、この点も合わせてある。

この上で、メタデータの解釈はPandocに委ねることにした。 従来は-Mオプションを付加して上書きしていたのだが、解釈の違いからバグにもなったし、Pandocのほうが優秀なのでこのような挙動は取りやめた。

ただし、PureBuilder的にはこれではちょっと困る。 サイトの内容に関する情報をメタデータに記述する風習があるため、メタデータに書ける内容が決められてしまうのは困るのだ。

そこで従来サポートされていた「ReSTでも先頭をコメントにした場合はそのあとYAMLメタデータを書くものとする」という仕様も維持している。 この場合、Pandocは解釈できないため、@extra_meta_formatというインスタンス変数を追加し、これが真の場合のみ-Mオプションでのメタデータを使うことにした。

こうしてReSTも完全対応できることとなった。 asciidocも結構人気があるらしいのだけど、Pandocが入力にasciidocをサポートしていないのでサポートすることはできない。 Textileをサポートしてほしい人がいれば対応は考えなくもないけれど、私が知る限りPureBuilderでTextileを使いたい人はいないはずだ。1


  1. 比較的複雑な構造を書けることがPureBuilderの利点であり、また簡易な記述をしたい人にとってもMarkdownが困ることはないはずだからだ。Textileにはコメント機能があるため、サポートすること自体は不可能ではない。

先のシェルスクリプトを形にしました

一時キーボード無効

Temporary Disable Keyboard @GitHub

Xinputを使用してデバイスを無効/有効にするためのもの。

主な変更点は次の通り

  • 無効化を「時間制」「ダイアログ」「無限」から選択できるようにした
  • Zenityダイアログを若干調整
  • ランチャー用の.desktopファイルを追加

Zshスクリプトだが、case内で非常に珍しい;&(フォールスルーする)を使用している。

ターミナルエミュレータ選択機能

Terminal Selector @GitHub

主な変更点は次の通り

  • ランチャー用の.desktopファイルを追加
  • KDE Service用の.desktopファイルを追加
  • Nemo用の.nemo_actionファイルを追加
  • 対応する端末を大幅に増加
  • 利用できない端末を選択肢から除外するように変更
  • Zenityダイアログを調整
  • 柔軟に端末を追加できるようにファイルマネージャでの起動用の引数対応が連想配列で使用できるように変更

連想配列を使用するための方式は次のようなものだ。

わざわざ連想配列をテンプレート文字列とし、そこから配列に変換している理由は

  • 連想配列に格納できるのは文字列のみ
  • 置き換えしてからではDIRがIFSを含む可能性がある

Linuxでキーボードをつないだまま掃除したい

キーボードが汚れているなぁ、と気づいたとき、あるいはキーボードがべたつくとき、キーボードを掃除したいけれど電源を切りたくないということはないだろうか。 特にデスクトップの後方につないでいる場合や、ラップトップの場合にはキーボードを外すのも面倒な話だ。 スリープにしてもキーボードに触ったら復帰してしまうし、なかなか厄介だ。

そこで一時的にキーボードをつないだまま無効にする方法を考えてみた。

キーボードを無効にする方法としてはXによるデバイス認識であればxinputが利用できる。

次の方法で認識されているデバイスを確認する。

あまり見やすくはない。

⎡ Virtual core pointer                      id=2    [master pointer  (3)]
⎜   ↳ Virtual core XTEST pointer                id=4    [slave  pointer  (2)]
⎜   ↳ ELECOM ELECOM UltimateLaser Mouse         id=9    [slave  pointer  (2)]
⎣ Virtual core keyboard                     id=3    [master keyboard (2)]
    ↳ Virtual core XTEST keyboard               id=5    [slave  keyboard (3)]
    ↳ Power Button                              id=6    [slave  keyboard (3)]
    ↳ Power Button                              id=7    [slave  keyboard (3)]
    ↳ FCL USB Keyboard                          id=8    [slave  keyboard (3)]

キーボード全体をオフにしてしまうと困るかもしれない。 なので、Virtual core keyboardをオフにすることは思いとどまったほうがいいだろう。

FCL USB Keyboard(idは8)を無効にするには次のようにする。(手前側の8がID、後のほうは固定の値)

有効に戻すにはこうだ。

外付けキーボードが他に用意できるならいきなり無効にしても構わないが、そうできない場合は時間で復帰するようにしたほうがいいだろう。

安全のためアプレットから復帰できるようにするほうがいいだろう。 マウスで操作できるよう、Zenityを使ってon/offできるようにした。 また、off時にはタイマーをかけることもでき、タイマーをかけない場合は確認される。

Gistで公開している。 Zshスクリプトであり、実際にZshの機能を使用しているのでBashでは動かない。 Zenityはなかなかコマンド置換が難しく、(f)は欠かせない。

Mimir Yokohama ウェブサイトの「タグ機能」の仕組み

Mimir Yokohamaのウェブサイトにこっそりとタグ機能が追加された。

だが、PureBuilder Simply自体にはタグ機能がない。 この実現方法は発想力勝負な部分があった。

ドキュメントにデータを持たせる

「記事情報」でも行われている方法として、Markdown YAML Frontmatter内に情報をもたせ、Pandocテンプレートで存在する場合だけエレメントを生成するような手法を取っている。

例えば帯域においては

というYAML Frontmatterが書かれている。 記事情報などは自動的に生成することができないため記事ごとにかかれており、若干執筆コストを上げている面もあるが、 なにしろMimir Yokohamaには力が入っているのでそれくらいどうということはない。 基本的なフォーマットをコピペしてしまえばそれほど難しくない部分でもある。

ちなみに、Pandocテンプレートで

$if(pickable)$
Something
$endif$

とした場合、pickable: no (つまりfalse)ならばここは生成されない。

この追加情報としてtagsが加わったのである。

問題は検索

タグを表示することは簡単だが、普通に考えればタグから同一タグの記事を辿りたいし、タグで検索もしたい。

単純な方法としてGoogleを使うこともできるのだが、それは必ずしもタグつきの記事が上位にくるわけではなく、思ったようには動作しない。 ちゃんと検索機能を用意する必要があったのだが、できればPureBuilder Simplyの枠組みの中で行いたいところである。

PureBuilder Simplyは原則として「MarkdownまたはReST文書から生成する」という前提になっており、 ACCSもindexデータベースからMarkdownドキュメントを生成し、このあとはPandocで処理している。

なのでPureBuilder Simplyの枠組みで処理するためにはMarkdownドキュメントを生成しなくてはいけない。

それなら全てのindexを探し回ってタグを集めればいいじゃない。

ARGV.eachしているので、全ての.indexes.rbmを指定すれば良い。 あとはpbsimply-pandocで処理できるが、タグに登場した.indexes.rbmは実際に記事が存在しているものではなく拾ってほしくないので消しておく。

.indexes.rbmとして書き出すようにした意図の一部に、このように外部からドキュメントデータにアクセスするというものがあった。

これによってドキュメント解析しなくてもメタデータを利用して機能拡張してページに含むことができる。

テンプレートにとうとう限界が

だいぶ魔改造されているPandocテンプレートだが、今回は限界が垣間見えた。

タグクラウドらしくエントリ数の多いタグを大きく表示したいのだが、Pandocテンプレートに計算機能や比較機能がなく、CSSにもないため、 Pandocテンプレートだけでは実現できない。MarkdownにHTMLを直接書くという方法はあるが(Markdown自体はRubyで生成しているため)。

また、URIエンコーディングをする方法はデータを二重に持たせる以外になく、それでもふたつの値を同時にとるイテレータがPandocテンプレートにないため、URIだけでURIエンコーディングをおこなう方法がない。

eRubyを使ってもいいのだが、できれば使いたくない。 現時点ではタグクラウドの大きさ分けはしておらず、URIもURIエンコーディングせずに使用している。

継承の使いどころ

業務のほうでやっていたプログラミングが久しぶりに設計を4回もやる苦戦となった。

苦戦というよりは模索したというほうが近いが、「作るより変更するほうが難しい」の一例となった。

作業としては、出力機能にバリエーションが増えることだった。

入力データは変わらないのだが、出力データに関してはその違いが様々な面で出てくる(文字エンコーディングの違いなのだが、JIS XかUnicodeかという違いでもある)。 そのため入力処理あるいはデータアッセンブルの段階で処理してもいいし、出力時に処理してもいいし…という状態だった。

既に600行を越えているプログラムであるため、あまり変更すると変更量が増えてしんどいしバグにもなりかねない。 かといって単純に分岐してしまうとメンテナンスが困難になり、リファクタリングしておくほうがいい。

大部分は同じだが、変数で済むほどには同じじゃない、という加減が問題だった。

データ用クラスを作る

最初に考えたのがこれだった。

現状、データ自体は複数のインスタンス変数に分かれたHashで保持している。

これをクラスとして独立させ、これに出力機能を与える、というものだった。

この際、データをストアするメソッド(現状ではHash#[]->Array#push)にデータの加工を含めるという方法を考えた。

だが、これは現在とは異なるフローである。現状では入力処理はデータのアッセンブルと出力データのための加工を並行して行っている。 これを実現するためにはこの点も変更しなければならず、一見スマートに見える方法だったが、これは呼び出し側から見ればカプセル化によってスマートに見えるだけで、実際はあまりキレイな実装にはならなそうであった。

一見ゴリ押しに見える現在の設計だが、再考してみると案外合理的なのである。 大幅な変更がリファクタリングとして有効に働くかは結構疑問であった。

また、データオブジェクトだけではなくフロー制御で使われるデータもあり、 データオブジェクトとフロークラスでいくつものインスタンス変数を共有する必要があった。 これはあまり美しくない。

変更点が大きくバグを生じる可能性が高かったため、この方法は断念した。

異なる部分をメソッド分けする

「避けたい」と思いつつもどれくらい複雑になるか考えてみた。

これまでひとつのメソッドに組み込まれていた一部をパーツとして分離し、メソッドとして切り出す。 もちろん、これは悪いことではない。

そして、異なる処理を行うメソッドを追加する。 ここまではまだいい。

だが、そのどちらのメソッドを起動するかということについては従来のメソッド上で条件分岐しなくてはいけない。

別に今後増えることも減ることもないのならばそれでもいい。 だが、今回追加されたJIS機能は「将来的に削除する予定の」機能である。 実際仕様書にも「容易にバイパスできるように」と書いてある。

フラグだけで条件分岐を回避するというだけのコードは明らかに汚くなってしまう。

違いをモジュールで吸収する

「違いが生じる部分をMix-inすることにして、Mix-inするモジュールの選択によって挙動を変えよう」というアイディアが生じた。

これは、出力するたびに入力をやり直すことになるが、フィルタとして機能するようなプログラムではないのであまり問題はない。 入力処理もそこまで極端に重いわけでもない。 入力データのSanity checkや分類にも両者に違いがあるため、実行時は分離したほうが良い考えに思えたのだ。

もちろん、両方生成する場合は非効率的だが、その程度は受容できるだろう。

だが、このアイディアはすぐ適切でないと分かった。 出力系統によって一部だけ変更/追加したい、ということがあるのだが、共通処理から分岐するたびにメソッドの有無をチェックしてあるなら呼び出す…ということになってしまう。 これは明らかにsuperしたい状況であるにも関わらず、だ。

だが、Mix-inされたモジュールのインスタンスメソッドよりクラスのインスタンスメソッドのほうが優先度が高いため、これはうまくいかない。 比較的新しい機能である(といってもRuby 2.0だけれど)Module#prependしても良いのだけれど、これはちょっと違うように思われる。 そもそも正しい継承関係と逆で、設計が歪なのだ。

継承する

「出力の固有特性を持っているほうがサブクラス」であることが自然だと思うなら、サブクラスにすれば良いじゃない!というわけで継承することにした。

従来のクラス名を維持すれば互換性を保つこともできるが、従来型(Unicode)と新機能(JIS)は並列の存在であるため、名称をABCABC::DEFのような関係にはし難い。 そこで、ベースクラスはベースクラスで名称をつけ、UnicodeクラスとJISクラスを用意することにした。

この構造は、共通部分も多いがそれぞれ部分的に違い、また全面的にオーバーライドできるものではないため、 共通処理をベースクラスに記述し、サブクラスからsuperを呼んだり、メソッド中で部分的に異なる点はサブクラスで実装されているメソッドで異なる処理をする。

リファクタリングするならば差異のある部分のメソッド構造を変更し、容易にオーバーライドできるようにする、また処理の一部はコール時のブロックにする、といったことが考えられる。

共通部分があって一部違う挙動を示すものがあるとき、継承を使う、ということ自体はごく当たり前のことだ。

しかし、「従来一本道だった処理で複数の異なる挙動を持つプログラムにする」というときに新たに「共通のスーパークラスを作って」継承を利用する、という発想はなかなか出てこなかった。

しかし結果的には、これぞ継承の使い方という見本のようなものになったな、と思う。

アメブロから更新時に一部を抽出して通知する

私はかなりの頻度でアメブロのとあるブログエントリの更新をチェックしているのだが、ほとんどの場合重要な情報はそのごく一部である。 しかし、アメブロにスマホでアクセスするたびに、くだらない広告や関心のないTV出演者の話に通信量が割かれ、時間も割かれることを大変に腹立たしく思っていた。

そこで、PCからページを取得し、スクレイピングする方針で考えたものである。 スクレイピングした内容はDiscordで通知する。

具体的な内容は差し障るので抽象化する。

コード

解説

「アメブロから」という難しさ

ユーティリティスクリプトでは「問題自体を可能な限り引き下げ、ゆるく解決する」のが重要である。 開発コストを下げるべきなのだ。

amebloの内容を見ると、独特なエディタから生成されるものだけに、エディタ上での記述はちょっとした違いで大幅に異なるHTMLコンテンツが生成される。 私は投稿者とは連携していないので、ちょっとした記述のブレで抽出すべきコンテンツを見失う可能性がある。

生成されるタグと比べると見た目上の違いは少ない。 言い回しの違いや、スペースの有無程度である。

そこで、w3mを活用する。w3m-dumpオプションは、見た目に近い形でレンダリングした上でテキストにして出力する。 アメブロの執筆者はエディタ上の見た目で書いている可能性が高く、このようにレンダリングしてしまったほうがブレは抑えられる。

その中から情報を抽出する。 ある正規表現に一致する行、またはある正規表現の範囲をアドレスとして出力すれば多くの場合は十分だろう。

続くエントリや広告のゴミ値を拾ってしまわないように、エントリ終了時にはSedを終了させる。 多くの場合、/^AD$/で良いかと思うが、/いいね!した人.*リブログ/というパターンも考えられる。

例えばおやつ工房 ひびのやをdumpした結果からメニューを取得するとすれば、筆者はメニューの表記前に本日のメニューは、という行を置く習慣があるようで、またブログの締めとして本日もよろしくお願いいたしますを置いている。 これに基づけば

メニューそのものを抽出するならば、で囲むようにしているようなので、そこに限定しても良い。

しかし、どうもこれは項目であるようだから、メニューとしては成立しないかもしれない。 項目終わりは空行を空けているようなので、この項目から空行まで抽出してみよう。 一応、スペースを入れていた場合にも対応しておく。

しかし次のようなケースが発見された。

*天然酵母パン*
⚫︎食パン1斤→焼き上がりました
自家製カボス酵母、春よ恋使用。
乳製品不使用のシンプルなパンです。国産のお粉の素朴な風味をそのまま味わっていただけます。 

⚫︎レーズンとくるみのパン→焼き上がりました
自家製レモン酵母、春よ恋使用。

⚫︎カンパーニュ →焼き上がりました
自家製ラ・フランス酵母使用。
ライ麦粉と全粒粉を配合。

なので、項目開始だけでなく小項目開始も受け入れることにする。

結果次のように抽出できた。

*マフィン*
プレーン⬇
ポップシュガーに戻しました。
きのこみたい

*シフォンケーキ*
プレーン小

*天然酵母パン*
⚫食パン1斤→焼き上がりました
自家製カボス酵母、春よ恋使用。
乳製品不使用のシンプルなパンです。国産のお粉の素朴な風味をそのまま味わっていた
だけます。 

⚫レーズンとくるみのパン→焼き上がりました
自家製レモン酵母、春よ恋使用。

⚫カンパーニュ →焼き上がりました
自家製ラ・フランス酵母使用。
ライ麦粉と全粒粉を配合。

日付も入れるならば、投稿時タイムスタンプの行を追加しよう。

/[0-9]\{4\}-[0-1][0-9]-[0-3][0-9] [0-2][0-9]:[0-5][0-9]:[0-5][0-9]/ p
/*.**\|^⚫/, /^\s*$/ p
/^AD$/ q
//いいね!した人.*リブログ/ q

なお、サンプルとして「ameblo 本日のメニュー」で検索して最上位に出たおやつ工房 ひびのやさんを使わせていただいた。 埼玉県越谷市にあり、東京スカイツリーライン大袋駅、せんげん台駅が最寄りとなる、パンとお菓子のお店だそうである。11時から18時営業、金曜日休業。

更新されたかどうか

単純に考えれば取得したページをteeで保存して比較したいところだが、 広告などランダムに生成される部分があるため、これは失敗する。

amebloはDateヘッダを含み、Content-Lengthヘッダを含まないため1、ヘッダ部分だけ保存するという方法もある。 curl--dump-headerを使用し、内容は捨てるという方法が良いだろう。

もうひとつはそもそも抽出した内容を比較するという方法がある。 リクエスト回数が減るため合理的だ。

いずれにせよ、前回取得分をローテーションして、今回取得分と比較する。

diff -q ~/.cache/someameblo/header ~/.cache/someameblo/header.prev > /dev/null

通知する

今回の場合、Discordのwebhookを使用している。

Twitterという手もある。ここではtwを使用する。 セルフDMの送信自体は可能だが、Twitter webや公式アプリでは確認/通知はなくなっている。

LINE Notifyという方法もある。

メールが配送できる環境からであればMMS宛というのも悪くない

ジョブスケジューラによる定期実行

スクリプトを書いても手動実行するというのはちょっとださい。 ジョブスケジューラによって自動的に実行してほしいところだ。

anacronあたりを使うのが妥当なのだろうけれど、私はSystemd Timer (User)を使用することにした。

まずはserviceユニットを書く。 ここでは~/.config/systemd/someameblo.serviceを作成している。

[Unit]
Description=Get ameblo entry.

[Service]
Type=simple
ExecStart=/bin/zsh "/home/aki/opt/someameblo/getamebloentry.zsh"

[Install]
WantedBy=default.target

次に対応したtimerユニットを書く。 ここでは起動から10分で初回実行し、それ以降4時間ごとに起動している。

[Unit]
Description=Get ameblo entry.

[Timer]
OnBootSec=10min
OnUnitActiveSec=4hour
Unit=someameblo.service

[Install]
WantedBy=timers.target

ユーザーユニットをリロードする。

タイマーを起動&有効化

「ゆるい解決」について

このようなユーティリティは基本的に人間を補助するものである。 オートメーションに属するものの、このユーテリティが人間に無断で何かを決断するわけではない。 また、人間にとってこのユーテリティによって与えられる唯一絶対の情報というわけでもない。

できるだけ精度が高ければ嬉しいけれども、もしも例外的ケース(例えば著者が普段とはまるで違う書き方をした場合など)においてうまく機能しなかったとしたならば、それは単に普通にブログを見に行けばいいだけである。 例外的ケースにおいて問題が発生しないように設計し、成功しない可能性については許容する。

同様に情報が常に完璧なフォーマットであることを求める必要もない。 ソースが完璧なフォーマットで書かれない可能性は高いので、完璧を求めることは不毛である可能性が高い。 これもまた、不十分な情報となったときには自らブログを見に行く選択肢もあるのだ。

ただし、十分に機能させるためには、抽出条件はfalse negative(取りこぼしがある)よりはfalse positive(ゴミがはいる)ほうが好ましい。 ゴミがあっても無視すればいいが、取りこぼしがあると必要な情報が損なわれる可能性があるためだ。

プログラミングはプログラムを書くことよりも、どのようにアプローチするかということのほうが重要な場合は少なくない。 プログラミング初級者のうちは設計をないがしろにしがちだが、プログラムの出来や困難性はだいたい設計によって決まる。 今回の主題も、正攻法ではかなり難しい条件を、考え方を変えることで簡単な問題に変換しているということだ。


  1. あるいはcurlがContent-Lengthを含まないのかもしれない

久しぶりのPerlの使い心地は

Perl 5はもうすっかり廃れてしまった言語で、Perlが好きな私も最近はあまり長いプログラムは書かなくなっていた。

Perl自体が滅びたとか、恥ずかしいような風潮もあるのだけれども、LinuxerにとってはSedやAwkを発展させたさらなる便利ツールなので、Perlが活用できていないというのはコマンドライン活用が下手ということになるので、そっちのほうがだいぶ恥ずかしい話になる。

とはいえ、そういう日常的な作業としては長くても50行いかないような話であるため、プログラミングらしいプログラミングというのはPerlで行うことはごく少なくなってきている。 あえてPerlで書くことが好ましい状況というのは、$_をどれだけ多用するかという問題だし、そうなると長いコードを書くにはとても向かない状況が出来上がる。

そのため、「汚くてもいいのでインスタントに書く」ときだけPerlを使うようになるし、そもそもほとんどの場合ワンライナーで使うことになる。

こうなってくるとそれなりの長さのプログラムをPerlで書くという機会はめっきりなくなってしまい、50行を越えるプログラムを使うということは稀になってしまっていた。 50行に満たないプログラムなら、グローバル変数や雑な変数乱造は普通にあるし、サブルーチンやリファレンス、オブジェクトなどを使う機会はなかった。

だが、久しぶりに仕事で集中的にPerlを触った。 が、なかなか手ごわかった。

一番つらかったのは「デリファレンスの不明瞭さ」だ。

Rubyで言うと次のようなコード

result_list.each do |one_result|
fname = one_result[:fname]
one_result.each do |match|
linenum = match[:linenum_from] + 1
match[:lines].each do |line|
printf '%d %s', linenum, line
linenum += 1
end
end
end

Perlではこうなった。

foreach my $one_result (@{$result_list}) {
my $fname = ${$one_result}{fname};
foreach my $match (@{${$one_result}{result}}) {
my $linenum = ${$match}{linenum_from} + 1;
foreach my $line (@{${$match}{lines}}) {
FH->printf('%d %s', $linenum, $line);
$linenum++;
}
}
}

なにをどうデリファレンスするか、が複雑すぎて極めて混乱した。 単純にこれはリファレントを得たいということを実現できる仕組みになっていないのだ。 このあたりはもう少し美しい解法があってもよかったのではないかと思う。 Perl4との互換性から生まれた仕様という気もするが、このあたりの仕様がぐちゃぐちゃなPHPよりもひどい。

また、先のブログでも書いたWindows環境でのUnicodeファイル問題

open(FH, ">", "ภาษาไทยファイル名");

これが文字化けるのだ。Rubyだと問題ない。

File.open("ภาษาไทยファイル名", "w")

もっとも、RubyはMinGWで動作するし、PerlでもCygwinでは問題ないので、ここはPerlの問題とは違うかもしれない。 ただ、全体的にPerlが「アメリカ人の感覚だなぁ」と思うことはある。 優れていたはずの文字エンコーディングに対するサポートも、ちょっと甘い。

Perlの文字列操作は割と独特。 PerlはUCSを採用し、内部エンコーディングにUTF-8、内部改行文字にLFを採用している。 そして、「文字ベースで処理するにも関わらず、文字列としてバイナリがデフォルト」。

次の場合はいずれもバイナリ文字列となる。

my $str1 = "こんにちは世界";
my $str2 = <>;
open(FH, "<", "somefile");
my $str3 = <FH>;
close(FH);

次のようにすればいずれも内部文字列となる。

use utf8;
my $str1 = "こんにちは世界";
binmode(STDIN, ":utf8");
my $str2 = <>;
open(FH, "<:utf8", "somefile");
my $str3 = <FH>;
close(FH);
use open IN => ":utf8";
open(FH, "<", "somefile");
my $str4 = <FH>;
close(FH);

文字列は「バイナリ」か「内部文字列」かの2種類だ。 内部文字列はUTF-8+LFになっているので、そのまま出力すると元の文字エンコーディングに関わらずUTF-8で出力される。

open(FH, "<:encode(euc-jp):crlf", "somefile");
my $str = <FH>
close(FH)
print $str; # 入力はEUC-JPだったけれども、「内部文字列をそのまま出力」するとUTF-8になる

Perlは文字列操作を常に正規表現で行う。 固定文字列検索は次のような具合になる。

$str =~ /\Qこ.ん.に.ち.は\E/;

あるいは

my $search_q = quotemeta("こ.ん.に.ち.は");
$str =~ /$search_q/;

きちんと内部文字列にしておかないと次のようなコードは失敗する。

$str =~ tr/A-Z/A-Z/;

Perlでは「バイナリ」「内部文字列」と言っているが、その実ASCIIとUTF-8だったりする。 内部文字列フラグがない文字列は、ASCII前提の頭で処理されるのだ。

そして、困ったことにPerlでは内部文字列フラグつきの文字列をバイナリに戻す方法がない。 文字列を内部文字列化することはできる。

use Encode 'decode';
my $str = <>;
my $internal_str = decode("UTF-8", $str);

ただし、このとき必ず変換される。 すでにUTF-8であると言えば変換されないわけではなく、 UTF-8として不正な文字は?にされてしまう。

encodeした文字列はバイナリであるが、これも変換を伴う。なので、次のコードではASCIIに変換しようとして、いずれも変換できないために出力は??となる。

use Encode qw/encode decode/;
my $str = "世界";
my $utf = decode("UTF-8", $str);
my $asc = encode("ASCII", $utf);
print $asc;

内部文字列として処理したあとURIエンコーディングを行う必要があったため、怪しげな超絶技法を駆使したりした。

{
use bytes;
$qword =~ s/(\W)/"%".uc(unpack("H2",$1))/eg;
}

だが、UCSとして文字列を扱うための機能自体は備わっている、ということを感じる。 一応固定文字列検索をindexで行う方法もある。

use utf8;
binmode(STDIN, ":utf8");
while(<>) {
if (index($_, "こんにちは") >= 0) { print; }
}

substrは実用的ではないほど使いにくいため、s///を使わざるをえないが。 文字列操作は割と悪くない。Perlの得意分野だからか。 もっといまいちな文字列の取り扱いをする言語は多く、古いわりにはまっとうに動作する。

だが、文字列を問答無用で破壊的に変更してしまうのはちょっと辛いものがある。 例えば次のようなケースでは変更前の値を変更後に使用する必要があるためコピーが必要になる。

sub get_and_save($) {
my $qval = $_[0];
my $quri = $qval;
{
use bytes;
$quri =~ s/(\W)/"%".uc(unpack("H2",$1))/eg;
}
getstore("http://www.example.org/board?q=$quri", "${qval}.html"));
}

破壊的に変更したくない時は決して珍しくないので、Perlを微妙に感じる場合のひとつだ。

ちなみに、文字列の扱いに関してはRubyはCSIを採用するため「変わっている」。

文字列にはエンコーディングに関する情報を与えることができるが、 特にこの場合に変換はしない。

str.force_encoding("EUC-JP")

Ruby2.0からは文字列はdefault_externalの値のエンコーディングだとみなされる。ロケールに従うため、ここではja_JP.UTF-8を使用していることからUTF-8とみなされ、 変換しようとするとUTF-8からの変換が行われる。 次の例ではUTF-8からEUC-JPに変換している。

puts "こんにちは世界".encode("EUC-JP")

open時にPerlみたいな方法で指定することもできるようになっているが、 このときにPerlのように内部文字列の変換を行うわけではない。 次のコードではEUC-JPのファイルを読んでEUC-JPで出力する。

File.open("somefile", "r:euc-jp") {|f| puts f.gets }

EUC-JPと認識されているので、変換することはできる。

File.open("somefile", "r:euc-jp") {|f| puts f.gets.encode("UTF-8") }

あるいは内部エンコーディングを指定して自動で変換することもできる。

File.open("somefile", "r:euc-jp:utf-8") {|f| puts f.gets }

だが、Rubyは独特なので、Perlがモダンでないという話とは繋がっていない。 Rubyの文字エンコーディング関連は、日本人によるものだけに非常に楽だ。

Rubyと比べてという話にはなるのだけれど、JavaScriptやPHPとくらべても、彼らが当たり前に「よきに計らってくれる」ことを手動で面倒をみてあげないといけなかったりして、ちょっと不自由な感じがある。

文字列と数値をコンテキストで分けている、というのもちょっと微妙だ。 それだったらまだ型を指定させたほうがマシであるように感じる。 どうしても==eqの書き間違えなどは発生するし、割とデバッグもしづらい。

Perlへの批判は大部分がPerl4に起因するもので、「書き方次第」なのだが、統一感がないというか、ちょっとすっきりしない仕様というのはままある。 これはZshを使っていてbashに感じるものと似たもので、古いが故か洗練されていないというか、なにより直感に反する要素がしばしば入ってくるのがストレスだ。

まず、リファレンスを多様するのであれば他のプログラミング言語のほうが楽だ。 そして、デリファレンスをたくさん書くのであればオブジェクト指向で書くほうがずっとスマートだ。

Perlに求めていたのはそのあたりの改善だったのだが、それはPerl6によってではなく、RubyやPythonによって達成されてしまった。 だが、これらはすでに「Perlの良さ」はないため、Perlの新しいバージョンにはその改善を求めたかった。 実際にはPerl6は全く異なる言語であり、(なかなか良いように見えるが)望んだものとは少し違う。

依然としてUnixツールとしてのPerlは優れたものであり、これよりUnixツール的に優れた言語は登場していないが(Streemが実用レベルに達すればその状況は変わるかもしれない)、本格的なプログラミングにはいささか辛い部分も見え隠れする。 特に、「わかりづらくなりやすい」という点に関しては問題が大きく、さらに妙に面倒な状況が出る場合もある。

今、Perl5を使うにはいささかの割り切りが必要だ。 だが、他の大多数の言語よりも依然として良いのもまた事実。 プログラミング言語が業務的に語られることの多い昨今だが、優れた言語は優れた言語なのであるということを改めて実感させてくれる部分もあった。

Windows PerlでUnicodeなファイル名を使ってはまる

業務でPerlでプログラミングしていたのだが、 Unicodeなファイル名で出力していたら文字化けする、という症状が報告された。

ミニマムのコードとしては以下の通り

use utf8;

open(FH, ">", "てすとふぁいる");
FH->print("テスト出力");
close(FH);

これはLinuxでは問題なく動作するし、それについては実際に動作させて確認もしてある。 ところがWindowsではこれが文字化けする。

まぁ、NTFSはUTF-16LEだもんね。 というわけで対応してみる。

use utf8;
use encode;

open(FH, ">", encode("UTF-16LE", "てすとふぁいる");
FH->print("テスト出力");
close(FH);

ところがこれでもだめである。

もちろん、次のようにすれば動作するのは知っている。

use utf8;
use encode;

open(FH, ">", encode("cp932", "てすとふぁいる");
FH->print("テスト出力");
close(FH);

おそらくWindowsがShift-JISをシステムエンコーディングとして使っていて、FATの日本語ファイルにもShift-JISを使っていたこととの互換性のためにシステムが何かをしているのだろう。 だが、その何かの結果うまくいかなくなってしまう。 日本語ファイルならShift-JISで書くという手もあるが、今回の場合お客様の要望としてはファイル名にはハングルを使う。しかしシステムは日本語Windowsである。

さて困った。

Win32::Unicodeという手もあるようだが、これがUnicodeでかけるのかは結構疑問だ。 さらに言えば、今回の場合かなりの数のファイルを書くため、速度低下が無視できない。

ちなみに、Ruby (RubyInstaller)だと

File.open("てすとふぁいる", "w") {|f| f.puts "テスト出力" }

ちゃんと動作したりする。

テストしたところ、Active Perlでも、Strawberry Perlでも問題は同じ。 しかしCygwin Perlだと起きない。 Cygwinを使うというのは初心者にとってちょっとハードルが高いので避けたい部分ではあるのだが…

なお、RubyはMinGWを使っているから大丈夫なのかもしれない。

お仕事ではPerlも使えます。 今じゃ使える人も少ないので貴重なのではないかと思う。 ご依頼、お待ちしてます!!

シェルスクリプト Forced Todo

Forced Todoはなかなか進まない進捗に対して、一定の時間でTODOを読み上げ、進捗を迫るものである。 あまりに進捗しないので作ってみた。

Gist

Systray

Notifyを開始するとSystrayに常駐し、Systrayから終了できるようにした。

Systrayへの常駐はMailDeliver2同様、yadを使うようにした。

notifypid=$!

yad --notification --image=player-time --text="TODO NOW!" --command="yad --text-info --timeout=15 --title=TODO --width 300 --height 300 --filename=$HOME/.yek/forcedtodo/todotext" --menu='Edit!'"$task_editor $HOME/.yek/forcedtodo/todotext"'|Quit!quit' --tooptip="Immidiately Task" 
kill $!

Yadのオプションについて分析してみよう。

オプション 意味
--notification Systrayに常駐する
--image=player-time Systrayのアイコンを選択
--text="TODO NOW!" Notificationの名前(ポップアップに影響)
--command 左クリック時の実行コマンド
--menu 右クリックメニュー。項目名とコマンドを!で分けている。quitで終了

ネストされているYadは次の通り

オプション 意味
--text-info テキストダイアログ
--timeout=15 15秒でダイアログが閉じる
--title=TODO ウィンドウタイトル
--width 300 ウィンドウの幅
--height 300 ウィンドウの高さ
--filename=... テキストの内容となるファイル

Zenityとは結構な違いがあることに注意。

Yadは通常ブロックするため、Yadが終了しなければ戻らない。 そのため、Notifyするためのプログラムをサブシェルでバックグラウンドプロセスとして実行し、そのプロセスID($!)を取得し、Yadが戻ったらKillすれば良い、ということになる。

Notify (Open JTalk)

Notifyは単一のプロセスIDを振るためにサブシェルで実行し、バックグラウンドプロセスとすることでループさせる。

(
  while sleep $notify_interval
  do
  perl -pe 's/\n/。/g' ~/.yek/forcedtodo/todotext | open_jtalk "$jtalk_tuning[@]" -m $hts_voicefile -x $mecab_dictdir -ow $jtalk_tmpfile
  play $jtalk_tmpfile >| /dev/null &|
  notify-send -t $notify_time "TODO" "$(<$HOME/.yek/forcedtodo/todotext)"
  done
) &

単純なsleepによるループである。

基本的な手順としては

  1. Open JTalkで音声ファイルを作成
  2. 合成した音声ファイルをバックグラウンドプロセスとして再生
  3. Notify Sendを使って通知に表示

となる。

ここで大きな情報はOpen JTalkだろう。

Open JTalkはスピーチシンセサイザーだが、eSpeak, SVOX Pico, Festivalといったソフトウェアと比べて非常に自然で、日本語に対応している。 ただし、かなり複雑なソフトウェアだ。

Manjaro(Arch)では、AURにあるopen-jtalkパッケージをインストールする。

NAIST JdicファイルはAURにも存在していないが、辞書ディレクトリとして/usr/share/open-jtalk/dic/を使用することができる。精度は異なるかもしれない。

NAIST JDicをインストールするためには、mecabをインストールし、さらにNaist JDicで/usr/libexecが指定されている部分を/usr/libに変更しなくてはいけない。

女声を使用するためにはopen-jtalk-voice-meiをインストールする。

Open JTalkは非常に自然で、Twitterの読み上げにも使えそうだが(twとの組み合わせがいいだろう)、複数行を扱うことができないため、Perlで変換している。 また、Open JTalkは標準出力に吐くことはできないようだ。