Paradigm Shift Design

ISHITOYA Kentaro's blog.

How to Use mhsendmail in a Docker Container on Apple Silicon

Appleシリコンのコンテナでmhsendmailを使う方法。色々ググったんだけどmhsendmailが古すぎてAppleシリコンに対応しておらず。自前でビルドする方法を模索したログ。

When you search for methods to send emails to Mailhog in environments not using Laravel, you are often advised to use mhsendmail. However, on Apple Silicon, you encounter an error like this:

$ mhsendmail
runtime: failed to create new OS thread (have 2 already; errno=22)
fatal error: newosproc

... [Error Stack Trace] ...

Essentially, this is because it's not built for arm64.

Referring to the following documentation, https://docs.docker.com/build/guide/multi-stage/#add-stages, here is a Dockerfile intended for use with php:8.3. The reason for using git clone is that it seems go install does not support forks (though I haven't thoroughly checked):

FROM --platform=${BUILDPLATFORM} golang:1.21 AS golang
RUN apt-get update && apt upgrade -y && apt-get install -y git
WORKDIR /src
RUN git clone -b arm64 https://github.com/BKHZ/mhsendmail.git && cd mhsendmail
WORKDIR /src/mhsendmail
ENV CGO_ENABLED=0
COPY . .
ARG TARGETOS
ARG TARGETARCH
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go mod vendor
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /usr/local/bin/mhsendmail

FROM php:8.3-apache

COPY --from=golang /usr/local/bin/mhsendmail /usr/local/bin/mhsendmail

With the above Dockerfile, now it works fine.

FIFA Rankings and Elo Ratings of the Teams Participating in the Asian Cup 2023

アジアカップ2023出場国のFIFAランキングとEloレーティング一つにまとめた表が欲しかったのです。

I was looking for a table that consolidates the FIFA Rankings and Elo Ratings of the teams participating in the Asian Cup 2023.

It's troublesome to use the Elo ratings because even when filtered for Asia, it doesn't align with AFC (for example, Australia is treated as part of Oceania).

The FIFA rankings are based on the data from December 21, 2024. The World Football Elo Ratings are as of January 14, 2024 (I thought of this after the opening, so 5 matches have already been played. It's troublesome to correct, so let's just consider it as a margin of error).

www.fifa.com www.eloratings.net

Order by Group (Original sort)

Group Name 国名 FIFA Rank FIFA Point Elo Rank Elo Rating
A Qatar カタール 58 1407.3 70 1533
A China PR 中国 79 1299.49 98 1427
A Tajikistan タジキスタン 106 1195.07 110 1381
A Lebanon レバノン 107 1192.58 142 1297
B Australia オーストラリア 25 1539.22 27 1790
B Uzbekistan ウズベキスタン 68 1345.26 54 1631
B Syria シリア 91 1245.27 97 1431
B India インド 102 1200.8 154 1257
C IR Iran イラン 21 1565.08 20 1829
C United Arab Emirates UAE 64 1364.46 74 1517
C Hong Kong, China 香港 150 1042.93 176 1079
C Palestine パレスチナ 99 1217.6 103 1408
D Japan 日本 17 1620.19 13 1909
D Indonesia インドネシア 146 1064.01 152 1258
D Iraq イラク 63 1365.98 57 1615
D Vietnam ベトナム 94 1235.58 112 1378
E Korea Republic 韓国 23 1550.65 22 1803
E Malaysia マレーシア 130 1122.87 139 1304
E Jordan ヨルダン 87 1272.63 75 1513
E Bahrain バーレーン 86 1277.29 100 1424
F Saudi Arabia サウジアラビア 56 1421.06 62 1585
F Thailand タイ 113 1176.75 110 1381
F Kyrgyz Republic キルギス 98 1224.14 149 1268
F Oman オマーン 74 1324.89 67 1566

Order by Elo Ratings

Group Name 国名 FIFA Rank FIFA Point Elo Rank Elo Rating
D Japan 日本 17 1620.19 13 1909
C IR Iran イラン 21 1565.08 20 1829
E Korea Republic 韓国 23 1550.65 22 1803
B Australia オーストラリア 25 1539.22 27 1790
B Uzbekistan ウズベキスタン 68 1345.26 54 1631
D Iraq イラク 63 1365.98 57 1615
F Saudi Arabia サウジアラビア 56 1421.06 62 1585
F Oman オマーン 74 1324.89 67 1566
A Qatar カタール 58 1407.3 70 1533
C United Arab Emirates UAE 64 1364.46 74 1517
E Jordan ヨルダン 87 1272.63 75 1513
B Syria シリア 91 1245.27 97 1431
A China PR 中国 79 1299.49 98 1427
E Bahrain バーレーン 86 1277.29 100 1424
C Palestine パレスチナ 99 1217.6 103 1408
A Tajikistan タジキスタン 106 1195.07 110 1381
F Thailand タイ 113 1176.75 110 1381
D Vietnam ベトナム 94 1235.58 112 1378
E Malaysia マレーシア 130 1122.87 139 1304
A Lebanon レバノン 107 1192.58 142 1297
F Kyrgyz Republic キルギス 98 1224.14 149 1268
D Indonesia インドネシア 146 1064.01 152 1258
B India インド 102 1200.8 154 1257
C Hong Kong, China 香港 150 1042.93 176 1079

Order by FIFA Rank

Group Name 国名 FIFA Rank FIFA Point Elo Rank Elo Rating
D Japan 日本 17 1620.19 13 1909
C IR Iran イラン 21 1565.08 20 1829
E Korea Republic 韓国 23 1550.65 22 1803
B Australia オーストラリア 25 1539.22 27 1790
F Saudi Arabia サウジアラビア 56 1421.06 62 1585
A Qatar カタール 58 1407.3 70 1533
D Iraq イラク 63 1365.98 57 1615
C United Arab Emirates UAE 64 1364.46 74 1517
B Uzbekistan ウズベキスタン 68 1345.26 54 1631
F Oman オマーン 74 1324.89 67 1566
A China PR 中国 79 1299.49 98 1427
E Bahrain バーレーン 86 1277.29 100 1424
E Jordan ヨルダン 87 1272.63 75 1513
B Syria シリア 91 1245.27 97 1431
D Vietnam ベトナム 94 1235.58 112 1378
F Kyrgyz Republic キルギス 98 1224.14 149 1268
C Palestine パレスチナ 99 1217.6 103 1408
B India インド 102 1200.8 154 1257
A Tajikistan タジキスタン 106 1195.07 110 1381
A Lebanon レバノン 107 1192.58 142 1297
F Thailand タイ 113 1176.75 110 1381
E Malaysia マレーシア 130 1122.87 139 1304
D Indonesia インドネシア 146 1064.01 152 1258
C Hong Kong, China 香港 150 1042.93 176 1079

寝る時間を確保するために、動画の字幕を可能な限り効率的につけたいんだ...

最近、私が小学生の頃から、お世話になっている和久内先生がYoutubeを始めるということで、撮影と編集を手伝っています。

先生のチャンネルは、こちらです。

和久内明 文化・教養チャンネル「現代知の挑戦」

是非、ご覧ください。
できたらチャンネル登録してね。

はじめに

日記です。ログです。

超絶安易な気持ちで、安請け合い

動画を見ていただければわかるんですが、和久内先生がなんらかのテーマに基づいて全編ずっと話している動画なんですね。

で、撮影・編集で一番辛いのテロップを入れることなんですよ。

いやー、最初は軽い気持ちだったんです。

有名なYoutuber、 はじめしゃちょーとか、 ヒカルさんとか、 朝倉未来さんとか、 みてると、ちゃんと全文字起こしして、タイミング合わせて小気味よくテロップが出るんですよ。
音を出せない状況でもみられるように、ってことなのかな。どうなんだろう。

例えばこんな感じ。 www.youtube.com

それで、トークが中心だとやっぱりテロップ必須だよね!って。
しかも、Youtubeの字幕じゃなくて、動画の中に編集で入れた方が文字が大きくて読みやすいよね!って。
思ったんですよ。

Youtubeチャンネルの話を和久内先生としていた時、最初動画編集は外注するって言われて。
いやいやいや、と。
ヒカルさんとか朝倉未来さんとかの大御所でも、外注は雇ってるかもしれないけど、基本的には自分のチームで動画の編集やってるって聞いたことがあったから、 「最初は広告とかもないし、可能な限り低予算で始めましょう!」
「編集も管理も私ができるので任せてください!」
「先生は内容をしっかり考えてください、私が裏方は全部やるので!」
「できるだけ高頻度で、可能なら週2本配信しましょう!」
とか、ノリで言ってきたわけですよ。

いやね、動画の編集とかほとんどやったことないんだけれど、字幕って結局、書き起こしでしょみたいな。
昔、研究室でDiscussion Miningっていう研究を先輩のツッチーがやってて、動画見ながら書きおこししたことがあるから、その時の経験で、まぁ動画時間の3倍くらいで終わるよね!って。
うん、思ってたんですよ。

第1回の動画撮影と編集、無知にもほどがある

それで、第1回の動画を撮影して。
動画編集ならPremiereでしょ!とかいう安易な考えで、操作方法もわかんないのにぐぐりながら動画を読み込んで、動画を聞きつつ、書き起こしをして、タイムラインにエッセンシャルオブジェクトを配置していったわけですよ。

それがこの動画。
youtu.be

あとで字幕のスタイルとか変えたいなと思ったんだけど、動画だけ差し替えたりできないのね。実装のこと考えたらそりゃできないわな、って思うんだけど、差し替えられると思ってたから誤字ってたりします。

いや、それでね、12時間かかったんですよ。編集するのに。
この動画の元素材の長さは16分46秒で、12時間...
動画1分あたりの作業時間43分...???
3倍ってなんの話だったっけ...???

いやいやいや、週2で配信?
仕事しながら24時間捻出すんの?え?
無知にもほどがあるでしょ。

いやいやいや、慣れてなかったから時間かかったんだよと。
慣れてくれば半分くらいにはなるよ...ならないなぁ...

第2回の動画撮影と編集 Google先生に聞く

それで私ぐぐりました。簡単に動画に字幕を入れる方法がないかと。
「動画 字幕 効率」と聞くと、Google先生はいろいろ教えてくれました。

要約すると

  • 文字起こしとオブジェクト配置は別でやろう
  • Excelとかメモ帳で書き起こしをしよう
  • Photoshopでオブジェクト挿入しよう

って感じで。

ほほうと。たしかにPremiereのインターフェース上で書き起こしをすることはなかったなと、思いまして。
ただ、オブジェクト配置はPhotoShopね、ってことで調べたんですが、Premiereに配置した後に文字を修正しようと思うとPhotoShopまで戻らなくてはならないので、廃案で。
とりあえず、書き起こしだけでも使い慣れたエディタで入力してみましょう!ということで。

2本目の動画は、 youtu.be 素材動画が26分57秒...ッ

普通に書き起こしをし始めて、これ音声認識でいけるんじゃないのって。
でググったら、Google Documentsの音声認識がいいと。

ほほー、と思って試してみたんだけど、精度がダメダメ。
音声がきちんと撮れていないのか、あーうーが多いからなのか、ちょっと使い物にならなかった。

それでいろいろまた調べてみると、

という記事を見つけたので、早速Vrew公式サイトからアプリをダウンロードして使ってみた。 音声の認識精度はそこそこ。音声を聞きながらテロップを編集できて便利!とか思ったんだけれど

  • 音声認識がぼろっと落ちている場合がある
  • 認識精度はそこそことは言え、普通に30%くらいは認識間違いをしているので書き起こししないといけない
  • 書き起こしをしないといけないのに、エディタとプレーヤーが使いにくい

という感じで、ちょっと使いにくかった。

で、Vrewの記事読んでたら、Premiereに読み込ませることのできるXML形式がある、というので、ほほぅ、じゃそれを生成すればいいじゃんとなり。コードを書きました。 説明とか書いてないので、使えないかもですが。必要な人がいれば書きます...

github.com

finalcut proのXMLが特殊で、エッセンシャルグラフィックの情報をBASE64エンコードしてるんだけれど、UTF-16LEの文字コードの頭に数バイト変なプレフィックスがついてて、それがないとPremiereで認識してくれないのね。BOMなのかな...よくわかんないから、Premiereで出力したFinalCut ProのXMLの中のデータの一部分を切り取って作ったデータの頭にくっつけてみた。

$styleStartByte = mb_substr(base64_decode("Zg8AAAAAAAB7ACIAbQBTAGgAYQBkAG8"), 0, 8);
// ...
$xpath->query("$clipitemPath/filter[1]/effect/parameter[1]/value")->
  item(0)->nodeValue =
  base64_encode($styleStartByte . mb_convert_encoding(
    sprintf($captionTemplate, $caption->getText()), 'UTF-16LE'));

ら、うごいたので、やっぱなんかついてんだなー、まぁ動いたからいいかーみたいな感じで追求してない。
2-3時間でスクリプトはできて。

ちょっと悩んだのが、タイムライン上で字幕のエッセンシャルグラフィックをどこに置くか。本当はVrewみたいに、発話の頭出しができれば一番いいんだけれど。面倒くさいから、均等割つけにしてみた。まぁ、オブジェクトが自動で挿入されるだけマシだしね。と割り切って。

それで、スクリプトできたから書き起こしだーってことで、楽したいから音声認識サービスをいろいろ探してたんだけど、最終的にnottaというiOSアプリにした。
半年で5000円くらいなんだけど、認識精度が結構高いのと、使い勝手がいいので。
Macのプレビューアプリで、動画の音声だけ取り出して、iCloudに突っ込んで、ファイルをiCloud経由でnottaに読み込ませる。nottaで処理開始すると、1-2分?もっと短いかな。それくらいで処理が完了して、テキストファイルやSRTファイルを出力できる。

AI解析音声自動文字起こしTRASC(トラスク)|IMAGICA Lab. Inc. と言うのもあって、結構認識精度高いんだけど、アプリの方が決済周りとかも楽なんで、notta使うことにした。

そいで、nottaで作った音声認識結果のテキストファイルをコピって、エディタで開いて、時間消して、動画再生しながら誤認識結果を削除したり、フィラーを削除したり、挿入する字幕の文字数に合わせて1行の長さを調整したりして。1.5時間かかんないくらいで終わりました。Premiere上で書き起こしするのに比べて格段に楽だった。
なんで俺はPremiere上で書き起こししてたんだ...?

書き起こしの時って、動画を巻き戻したりしながら音声を何度も聞いて、文字にしていくんだけれど、ちょうどいいプレーヤーがなくて、いろいろプレーヤーとか探しました。
QuickTimePlayer、MKPlayerとか使ってみたのですが、あんまり良くないんだよなーとか我慢しながら使ってた。

完成した、下処理済みのテキストファイルを作ったスクリプトに渡して、

php -f generator.php -- \
  -f 書き起こしした字幕テキスト1行1エッセンシャルグラフィック.txt \
  -o 出力.xml

とコマンドを実行するとFinalCut ProのXMLが出力される。
これをPremiereで読み込んで、均等割つけされたエッセンシャルグラフィックをタイミング設定していくわけですよ。動画に含まれるフィラーを削除したりしながら。
7時間。合計で6.5時間。動画1分あたり、19分。
1本目が、43分だったから倍速以上!!!

第3-6回の動画撮影と編集 ひたすら修練

動画の長さ 作業時間 動画1分の作業時間(分) 手法
第1回 16分46秒 12時間00分 42.9 Premiereで頑張る
第2回 26分57秒 8時間30分 18.9 nottaとスクリプト利用
第3回 13分17秒 4時間03分 18.3 nottaとスクリプト利用
第4回 15分52秒 4時間39分 17.6 nottaとスクリプト利用
第5回 17分56秒 4時間50分 16.2 nottaとスクリプト利用
第6回 22分47秒 5時間51分 15.4 nottaとスクリプト利用

時間が短くなっていってるのは慣れが大きいのと、書き起こしのときにつかうプレーヤーも結果、結局Premiereで再生するのが最強という結論に。ショートカットキーもいろいろ覚えた。

再生・停止(スペースキー)、in点の設定(i)と、in点に移動(Shift-i)、シャトルの巻き戻し・停止・早送り(j/k/l)を覚えれば大体OK。

で、でもやっぱ4時間以上かかるわけですよ。
元動画が短ければいいけど、週2ペースなら、寝る時間削るしかないわけで...

第7回の動画撮影と編集 平均割付から進化

第6回の動画が22分47秒と長かったのもあって、ちょっとこの手法を追求して行って手が早くなったとしても、作業時間の短縮にはつながらないなと悟ってしまったわけです。
Vrewみたいに、発話の頭にエッセンシャルグラフィックを配置できたらもう少し楽なのにな...nottaが出力するテキストファイルに時間ついてたなーということで、 以下のロジックで、人手で謝り訂正したテキストを、nottaの出力したテキストファイルの時間にうまく対応づけしてみようと。

  • まず、nottaで解析したタイムコード付きの文字列を一文字ずつ、時間と対応づける
  • 人手で謝り訂正したテキストの1行ずつをsimilar_textにかける
  • similar_text ( string $string1 , string $string2 [, float &$percent = null ] ) : int
  • string1に人手で謝り訂正したテキストをカタカナに変換したもの
  • string2にstring1の半分〜2倍の長さの文字列を、1文字ずつずらしてカタカナに変換したもの
  • percentが一番高いものを取り出して、移動したインデックス+string2の長さ分インデックスを移動させる
  • その時の最初のindexをstart、最後のindexをendとする
php -f generator.php -- \
  -f 書き起こしした字幕テキスト1行1エッセンシャルグラフィック.txt \
  -c 音声認識エンジンで出力した開始時刻付きのテキスト.txt \
  -o 出力.xml

みたいに動かすと、音声認識エンジンで解析した開始時間付きのテキストに含まれる、謝り訂正した字幕テキストを探して、開始時刻を設定できるようにした。
その結果、大きくずれることもあるけれど、均等割つけをするよりもずっと効率が上がって、1分あたり12分40秒で処理できるようになりました。

おーおー。

第8回の動画撮影と編集 SRTファイルを使おう

nottaの出力するテキストファイルは、開始時刻だけついていて、終了時刻がないので、タイミングの精度がイマイチだった。
SRTファイルのフォーマット見たら、終了時刻もついてるじゃないの。
それで、同じようなロジックで、SRTファイルを読みこんで、終了時刻をみるようにした。

php -f generator.php -- \
  -f 書き起こしした字幕テキスト1行1エッセンシャルグラフィック.txt \
  -c 音声認識エンジンで出力したSRTファイル.srt \
  -o 出力.xml

とかって動かして、ついに動画1分あたり、10分切って、9分45秒!!!

やったー!寝れる!!!!!!

工程管理

ここでは、動画の字幕作成にフォーカスしましたが、工程はWrikeで管理してて、Blue Print用意して、毎回タスク管理しつつ時間計測しています。

f:id:kent013:20201226043853p:plain
WrikeのYoutube動画工程Blue Print

バイトでやってもいいよって言う人がいたら、連絡ください...

まとめ

動画の長さ 作業時間 動画1分の作業時間(分) 手法
第1回 16分46秒 12時間00分 42.9 Premiereで頑張る
第2回 26分57秒 8時間30分 18.9 nottaとスクリプト利用
第3回 13分17秒 4時間03分 18.3 nottaとスクリプト利用
第4回 15分52秒 4時間39分 17.6 nottaとスクリプト利用
第5回 17分56秒 4時間50分 16.2 nottaとスクリプト利用
第6回 22分47秒 5時間51分 15.4 nottaとスクリプト利用
第7回 18分45秒 4時間00分 12.8 nottaの時刻付きテキスト
第8回 15分01秒 2時間28分 9.8 nottaのSRT

SRTファイルに含まれる一つ字幕の長さができるだけ短い方が、より精度高く字幕を発話にマッチングできるんじゃないかなーと思って、いろいろな解析エンジンが出力するSRTファイルを見てみた。
Youtubeの自動解析は、細かく出してくれるんだけど、認識精度が低くて、かつ多分2行にわけて制御する前提なのか字幕と字幕が時間的に被ってたりするので使い物にならず。しかもSRTダウンロードできるようになるまでに2時間とかかかるし。
Vrewは認識できないと、テキスト生成を飛ばしちゃうのでダメで。
いろいろ試したけど、結局nottaが出力するSRTファイルでいいかな、となっています。

本当はjuliusとか使って音声認識しつつDPマッチングして行った方が精度上がるんでしょうけれど。次のトライでやってみようかなと思います。
動画作ってるので、正解があるからDeepLearning使った方がいいかもしれない。

で、どうしてVrew使わないのってとこなんですが

  • 字幕の誤り訂正と、下処理は慣れたテキストエディタの方が楽。リッチなUIとか必要ないし、その方が効率が良い
  • 字幕として出したい文字列やタイミングって、結局Vrewとかが解析したものと違うものになりがち
  • Premiere上でフィラーのトルツメするのとタイミング合わせは同時にやりたい

という要求があり、Vrew使うとむしろ編集に時間かかるんですよね。

とりあえず、10分切ったから、週2で公開しようと思っても、一応寝れる感じにはなってきた、ということでログでした。

スクリプトは使いたい人がいればどうぞ。ちゃんと説明してよ、って言われたら説明書きます。

github.com

長くなっちゃった...

さいごに

和久内明先生の文化・教養チャンネル「現代知の挑戦」は、こちらから。
https://www.youtube.com/channel/UCscmiGezlYmy7UpDxtJBtRA

現時点での最新話は、第8話です。 youtu.be 生き残っていく知ってどんな知なんだろう、残したい知、伝えたい知ってなんだろう。
和久内先生の視点から、いろいろな詩人・小説家・文学作品を紹介しつつ、語るチャンネルです。

興味のある方は是非どうぞ。

サッカー記事の一覧サイトを作った(Gensimを利用して類似記事を表示するまでの記録)

はじめに

個人的に、サッカーのニュースや2chまとめ記事を一覧できるサイトを結構な頻度で見る。

おそらくは、単にRSSでデータ引っこ抜いてきて一覧で表示しているだけのサイトなんだれども、ニュースとまとめBlogとかの記事が別れていたり、自分の好きなニュースソースがなかったりするのがずっと不満だった。

昔みたいにRSSリーダーRSSを登録して読めばいいんだけれど、なんだかそういう習慣もなくなってしまっていて、RSSリーダーを探すところから始めるのもなーと。

そんなことを思いつつ1年ぐらい過ごしていたんだけれど、なんだか最近プログラムを書く機会もないし、自然言語界隈で新しいライブラリも出てきてるみたいだし、ということで、勉強がてら自分で作って見た。

使われることはないだろうとは思いつつも、ソースコード使ったら教えてもらえると嬉しいです。

なにかの役に立てばと思うので、勉強の成果を残します。

やりたいこと

  1. サッカーに関連する、ニュースや2chまとめサイト、個人ブログなどの記事を一覧できる
  2. ある記事に関連する記事を自動で抽出して、表示してくれる

やったこと

やりたいことが明確だったので、下調べしてフィージビリティとるとかしないで、

  1. RSSの収集
  2. 記事の取得
  3. 本文およびメイン画像抽出
  4. 本文の形態素解析
  5. 類似文書の計算
  6. クローラサーバの構築
  7. Webサイトの構築

という流れで順にやった。

昔書いたスクリプトがあるとはいえ、全体で10日(40時間程度)でできちゃったのがびびった。 ググればライブラリが出てくるわ出てくるわで、人類の進化をマジで感じた。

RSSの収集

今は、どこのサイトもRSSついてるのね。RSSリーダー使っている人まだいるんだろうか。それとも誰も使わないけどなんとなくあるんだろうか。 収集したものをSpreadsheetにまとめて、面倒くさいからSQLを構築できるようにした

記事の取得

Scrapyで取得。

特筆すべきことはないけれど、昔作ったcrawlerのスクリプトが手元にあったので改変して利用。 Python2時代に描いたやつなのでwarningが色々出るけど動くから無視。

基本はDBにあるcrawler_jobを登録していって、crawlerのプロセスがjobを一個とって処理する感じの実装になっている。 新しいサービスとかあるんでしょうけれど、目的は類似記事の抽出なので、追求せず放置。

RSSからの記事の取得周りとか、昔はRSS0.91/1.0/2.0、Atomに対応するために別々のライブラリを使ったり、なんなら自分で記述してたけど、pip install feedparserして、

rss = feedparser.parse(rawdata)

for entry in rss.entries:
      article = FootballArticleItem()
      article['title'] = entry.title
      article['summary'] = entry.summary if 'summary' in entry else ""
      article['creator'] = entry.author if 'author' in entry else ""

      article['url'] = entry.link
      article['hash'] = article_hash
      article['subject'] = " ".join(map(str, entry.tags)) if 'tags' in entry else ""
      article['feed_id'] = self.crawler_job.feed_id
      if 'updated_parsed' in entry:
          article['published_at'] = datetime.fromtimestamp(mktime(entry.updated_parsed))
      elif 'published_parsed' in entry:
          article['published_at'] = datetime.fromtimestamp(mktime(entry.published_parsed))
      article['scraped_at'] = self.crawler_job.started_at

      if 'published_at' in article and article['published_at'] > datetime.now():
          article['published_at'] = datetime.now()

みたいにサクッとかけちゃうのびびった。世の中進化してる。本当にすごい。

本文およびメイン画像抽出

本文抽出

記事が保存できてもHTMLなので、そこから形態素解析するためには本文抽出しないといけない。 自分で書かないといけないのかと思ってたら、色々とライブラリがあった。すごい。

https://moz.com/devblog/benchmarking-python-content-extraction-algorithms-dragnet-readability-goose-and-eatiht/ https://orangain.hatenablog.com/entry/content-extraction-from-html-in-python に書いてある全てのライブラリを試してみた。

あるわあるわ。すげーね。で、2chまとめサイトにかけてみたら...どれもダメ。全然ダメ。 英語圏の本文抽出エンジンだから日本語には適用できないんだなー、どうしようもないなーとおもってたら、日本人が書いてるのがあった。 http://labs.cybozu.co.jp/blog/nakatani/2007/09/web_1.html https://github.com/yono/python-extractcontent でも、日付をみたら2007...orz

でも、2015にpython3用に書き直している人がいた。いや、いらっしゃった。

https://github.com/kanjirz50/python-extractcontent3

人類は確実に進化している。動かしたら、他のライブラリよりもずっといい結果(目視です)だったので、こちらを採用。dragnetは英語の記事の形態素解析に利用することにした。ありがたやありがたや。

if feed.language == 'ja':
    extractor.analyse(content)
    text, title = extractor.as_text()
else:
    text = extract_content(content)

書いたコード、これだけですよ。びびるわそんなん。俺のためにライブラリ書いてくれてたのかとか錯覚するわ。

用途がはっきりしてるなら、最近だと

とか使えばいいのかもしれないけどね。

メイン画像抽出

メイン画像の抽出はいいライブラリが見つけられなかった(ググり力)ので、適当に書いた。

http://effbot.org/zone/pil-image-size.htm でネット上の画像のヘッダ部分だけをDLしてPillowでサイズ取得みたいなのをやっている人がいたので、それを利用。同じ画像を処理するときにはWebにアクセスしなくていいようにdiskcacheでキャッシュするようにした。

メイン画像の取得部分は以下の通り。アルゴリズム的には

  1. 画像のURLを取得
  2. ゴミ除去
  3. サイズ取得してきて、大きさとアスペクト比を比較。
  4. 大きくて四角に近い画像をメイン画像とする

みたいなシンプルなもの。なんかちゃんととれないけど、Webサイトにした時の見た目的に必要なだけで、本質とは関係ないのでいいやー、って感じ。本当は、記事上の位置とか調べたほうがいいんだけど、HTMLから取得しようとするの無理があるだろって思うので、もういい。

primary_image_url = None

for image_url in bs.find_all('img'):
    src = image_url.get('src')

    if not src or re.match('data:image', src):
        continue

    if not re.match('http', src):
        src = urljoin(article.url, src)

    if re.search('common|share|button|footer|header|head|logo|menu|banner|parts|thumbnail|ranking|icon|copyright|feedly|ico|seesaablog.gif|fan_read.gif|fan_received.gif|captcha|/n.gif|/u.gif|chart.apis.google.com|images-amazon.com|facebook.com|powered_by|rss.rssad.jp|blank|navi|custom.search.yahoo.co.jp|pixel|xrea.com|w=64|i2i|microad.jp|resize.blogsys.jp|b.hatena.ne.jp|accesstrade.net|poweredby|scorecardresearch.com|ssc.api.bbc.com|sa.bbc.co.uk|amazon-adsystem.com|zero-tools.com|clicktrack2.ziyu.net|nakanohito.jp|pv.geki.jp|arrow_left|arrow_right|spacer.gif|spike.png|wp-content/themes', src):
        continue
    print("  " + src)
    width, height = getsizes(src, dc)
    if not width:
        continue
    square = width * height
    aspect_ratio = width / height
    if square > max and aspect_ratio > 0.5 and aspect_ratio < 1.8:
        primary_image_url = src
        max = square

本文の形態素解析

本文が抽出できたので次は形態素解析だ!とおもって"python 形態素解析"で検索したら、

Python初心者が1時間以内にjanomeで形態素解析できた方法とかいうQiitaの記事が出てきた。janome...mecabから進化したのかーと思い、とりあえずインストールしてつかってみたら、Python janomeのanalyzerが便利とかにあるように、とても便利だった。

自分で書かなきゃいけない分量がかなり減っていて、これまた人類の進化を感じるわけであります。

class FootballCompoundNounFilter(TokenFilter):
    def apply(self, tokens):
        _ret = None
        re_katakana = re.compile(r'[\u30A1-\u30F4]+')
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if _ret:
                ret_parts = _ret.part_of_speech.split(',')
                if parts[0] == u'名詞' and not parts[1] == 'u固有名詞' and ret_parts[0] == u'名詞' and not ret_parts[1] == u'接尾':
                    _ret.surface += token.surface
                    _ret.part_of_speech = u'名詞,複合,*,*'
                    _ret.base_form += token.base_form
                    _ret.reading += token.reading
                    _ret.phonetic += token.phonetic
                else:
                    ret = _ret
                    if parts[0] == u'名詞' and parts[1] == u'固有名詞':
                        yield token
                    else:
                        _ret = token
                    yield ret
            else:
                _ret = token
        if _ret:
            yield _ret

class FootballNounFilter(TokenFilter):
    def apply(self, tokens):
        for token in tokens:
            parts = token.part_of_speech.split(',')
            if re.match('[0-9]+', token.surface):
                continue
            if parts[0] == u'名詞' and parts[1] == u'非自立':
                continue
            if parts[0] == u'名詞' and parts[1] == u'接尾':
                continue
            if re.search('[0-9]+([年月日分]|ゴール)$', token.surface):
                continue
            if re.search('[0-9]+$', token.surface):
                continue
            if token.surface in [u'次ページ']:
                continue
            yield token

こんな感じのフィルタを書いて、

char_filters = [UnicodeNormalizeCharFilter(), RegexReplaceCharFilter('&[^&]+;', '')]
tokenizer = Tokenizer(mmap=True)
token_filters = [FootballCompoundNounFilter(), FootballNounFilter(), POSKeepFilter('名詞')]
analyzer = Analyzer(char_filters, tokenizer, token_filters)

みたいに初期化したら、

tokens = list(analyzer.analyze(content))

みたいなコードで形態素解析できちゃうんですよ...すごい。なおフィルタの中身は、なんか適当です。

ちなみに、英語はNLTKで、

from nltk.tokenize import word_tokenize
words = word_tokenize(content)

ですよ。なんなんだ、この時代は。

類似文書の計算

形態素解析できたら、Doc2Vecに食わせて類似度計算です。

ここまで正味2日(16時間)くらいできたので、マジでやばい感じに脳内麻薬が出てしまっていました。 びびる。

Python と gensim で doc2vec を使うとか読むと、なにやら、TaggedDocumentの配列を用意して、Doc2Vecインスタンス作って、trainingして、類似する文書を取得するだけみたいで、マジかよ感が。

results = session.query(ArticleContents, Articles, Feeds).filter(Articles.hash == ArticleContents.article_hash, Articles.feed_id == Feeds.id, ArticleContents.extracted_content != None).order_by(ArticleContents.id).all()

for result in results:
    try:
        article_content, article, feed = result
        if article.hash in trainings:
            continue
        if not article_content.extracted_content:
            continue
        print('  ' + article.url)
        content = article_content.extracted_content
        words = []
        if feed.language == "ja":
            tokens = list(analyzer.analyze(content))
            for token in tokens:
                words.append(token.surface)
                #print(token)

        elif feed.language == "en":
            words = word_tokenize(content)
        trainings[article.hash] = TaggedDocument(words, tags=[article.hash])

みたいな感じでTaggedDocumentの配列(ここではキャッシュのためにDictionary)作って、

m = Doc2Vec(documents=trainings.values(), dm=1, vector_size=500, window=5, min_count=2, sample=8, alpha=0.1, epochs=55)
m.save("doc2vec.model")

ってして、

m = Doc2Vec.load('doc2vec.model')
similar_articles = m.docvecs.most_similar(article.hash, topn=20)

これだけで、類似する記事が出てくる。topnは出力する記事数ね。デフォルトだと10出力されます。

Doc2Vecのオプションに関しては、min_countとwindow/sampleを元となる文書群の規模に応じて変える必要がありそう。今の所4000記事とかしかないので、このくらいの数字じゃないといい感じに出てこない。 形態素解析の段階でゴミをどれだけ除去できるかも影響が大きい。

そんなことよりも!!!!! 4000記事のtrainingに10秒とかしかかかんないのなんなの!!!!

人類やばい。 類似度計算まで3日...24時間で終わっちゃうとかなんなんだ。

クローラサーバの構築

ここは、昔使ってたスクリプトを流用して構築した。 AWSを使ってます。

  • クローラサーバ2台 (micro)
  • 解析用サーバ1台 (small)
  • Web用サーバ1台 (micro)
  • RDS (small)

で、Route53でドメイン取って、ALB置いて、構築しました。 terraformとか使おうかなって思ったけど、そこ本質じゃないしやめた。

Webサーバはphp71-fpmとnginxで構築した。

Webサイトの構築

Webサイトは、こだわりポイントもあまりないので、PHPで記述した。

なんか適当なルーター(Klein)と適当なテンプレートエンジン(Twig)をつかって、見た目はBootstrapで。

記事ごとに、メイン画像を抽出しておいたので、それを表示したいなとおもったけれど、自前で画像を取得してきてリサイズしてS3に置くとか面倒臭かったので、そういうproxyがないか調べたら、あった。

で、上にもあるようにせっかくAWSSSL使えるのに、画像がhttpだとワーニングがたくさん出るので、Images.weserv.nlを使うことにした。

Faviconを表示したいなーと思ってググったらFaviconを取得するというのを見つけた。

http://www.google.com/s2/favicons?domain=www.yahoo.co.jp

だけとかびびるわ。

所感

昔、hatenaの記事を形態素解析して、トピック抽出しようとしていたことがありました。

もう10年前なのか...このとき、形態素解析してnumpy使って次元縮約して、自分でトピック抽出しようとしていました。あるユーザーがよく見るWebサイトの情報を収集してトピック抽出して、新しいWeb記事をサジェストしてくれるサービスにすれば儲かるんじゃないかっておもって。そのあと、GunosyとかSmartNewsとかでてきたんだけど、あの頃は、類似文書の出力とか、一部のスーパープログラマー達にしか実現することができなかったし、それこそ自然言語についての論文を広く読んでなかったらできなかった。

それが、類似度の計算までたったの24時間で到達できて、適当にWebシステムにして公開するまでに正味40時間ですよ。

もう、なんだか、今のプログラマ達は別の世界に生きているんだなって、痛感した。

とりあえず目的のものはできたので、あとはまた気が向いたら色々やってみようかなーって感じです。

まぁ、何かの役に立てば幸いです。

使ったもの

python

PHP

フロントは適当なので、てなりで書けるPHP

その他

php56-httpのインストール

I've run into problem installing php56-http with command

brew install php56-http
==> Installing php56-http
==> Downloading http://pecl.php.net/get/pecl_http-2.1.2.tgz
Already downloaded: /Library/Caches/Homebrew/php56-http-2.1.2.tgz
==> PHP_AUTOCONF="/usr/local/opt/autoconf/bin/autoconf" PHP_AUTOHEADER="/usr/loc
==> mkdir -p ext/raphf
==> mkdir -p ext/propro
==> ./configure --prefix=/usr/local/Cellar/php56-http/2.1.2 --with-php-config=/u
==> make
                        case CURLSSLBACKEND_QSOSSL:
                             ^
2 errors generated.
make: *** [php_http_client_curl.lo] Error 1
make: *** Waiting for unfinished jobs....

After googling, I found Cannot install php56-http · Issue #1429 · Homebrew/homebrew-php · GitHub

Open formula with command,

brew edit php56-http

Edit url and sha1.

url 'http://pecl.php.net/get/pecl_http-2.1.4.tgz'
sha1 'bcd2b925207ba06aa31608bd0b20008093caa61f'

And then run install command again.

brew install php56-http

クロスブラウザな画像マスク手法(FadeIn/FadeOut可能)

最近、クロスブラウザで画像と動画にマスクをかけるという案件がありまして、IEに呪いの言葉をぶつけながら解決したので、そのメモです。

ちなみに私は、フロントエンドエンジニアではなく、バックエンドエンジニアでもなく、ただのプログラマなのでCSSまわりとかおかしいところあるかもしれませんが。

なお、当記事は、The Nitty GrittyというサイトのChristian Schaeferさんが書いたThe Nitty Gritty: CSS Masks – How To Use Masking In CSS Nowという記事をものすごく参考にしています。

はじめに

なにをしたいかは、マスクテスト (Masking Test Script)を見ていただければ分かります。
(サンプルの画像がなぜネコ科のアレかというと、Schaeferさんの記事があのネズミだからです)

つまり
「画像を2枚重ねて、上側の画像を任意の形にマスキングして透過し、下側の画像が見えるようにする」
ということで、あらかじめ画像を用意できればいいんですが、そうでない場合いろいろごにょごにょせにゃならんということで。
github:masking-testにコードがおいてあるので、サンプルのマスクテスト (Masking Test Script)と見比べながら読んでみるとよくわかると思います...

画像のマスキング

画像のマスキングそのものは、さっきのSchaeferさんの記事にあるようにやればOKです。
が、いくつかバグがあります…ブラウザの。

WebKit

WebKit様は、一行でかつ完璧です。完璧!素晴らしい!素晴らしい!

-webkit-mask-image: url('../images/mask.png');

InternetExplorer 7/8

なんとIE7/IE8ではこのまま問題なく動くんですねぇ。素晴らしい。
あ、素晴らしいのはSchaeferさんであって、IEではないです。

OSX FireFox

マスクテスト (Masking Test Script)をOSXのFireFoxで開いて、Ex1の「Click to Fade In/Out 」ボタンを押してもらえれば分かるのですが、フェードというかネコ科のロボットが消えていなくなると思います。ブラウザのサイズ変えたり、上にウィンドウをかぶせてもらえれば分かるんですが、再描画のバグですかね。ちなみに、Ex1はSchaeferさんのやり方でマスキングした奴です。

clip-pathとかいうCSSプロパティがつかえるというので…

clip-path: url('../images/mask.svg#mask');

とか書いたったんですけどね…
FireFoxはIE系と同じForeignObjectを用いたマスク方法が使えるので、それを適用したのがEx2です。 四次元ポケットネコがちゃんとFadeIn/FadeOutすると思います。

InternetExplorer 9

IE9だけ挙動違うとか...爆発すればいい!けどなぁ… IE9のマスキングは、

<div style="width:400px; height:400px; filter: progid:DXImageTransform.Microsoft.Chroma(color='#00FFFF'); zoom: 1;">
  <img src="images/image.png"/>
  <!--[if lte IE 9]><img src="images/mask_mozie.png" style="display: block; margin-top: -400px;"><![endif]-->
</div>

のようにするみたいです。

親にChromaフィルタを指定して、color='#00FFFF'で指定したシアンの部分を抜いた画像が出力されます。 iemask
mask_mozie.pngはこんな感じなんで、image.pngからシアン部分を抜いたものが表示されるわけで。

で、IE9からopacityが使えるようになったようですが、それが中途半端なようでEx1/Ex2をIE9で動かしてもらえれば分かりますが、opacityが変更されると、mask_mozie.pngの色も変わってしまい色が抜けなくなってしまうみたいです… opacityをimage.pngにかけてみても変わらないため、

filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=20);

でAlphaを調整しています。この方法だとopacityと違って色が抜けるのね。
ただ、IE9で動かしてみれば分かりますが、残念ながら今のところOpacity=0になったら消えるんじゃなくて黒くなるのね…CSS設定しても何も変わらないし、仕事ではバックグラウンドが黒くてセフセフでしたので、追求してません。
原因が分かる方いらっしゃいましたら教えてください…

おわりに

IE6…? 爆発しろ!なお、Operaとか試してません。

追記

MojoliciousでPocketIOを使いつつセッションを共有する

さて念願の81忘年会、2次会まではよかったのですが、3次会は飲み過ぎてダウンしてました。 日曜日は久々に二日酔い。グロッキーで記事を書くつもりが…orz

というわけで、Goomerの技術的な話を、備忘録的にまとめて書いておきたいと思います。元記事はイベント内Likeツール、Goomer作りましたです。

Goomerは、HTML5WebSocketを使ったリアルタイムWebアプリケーションです。 WAFはMojoliciousを使っています。Mojoliciousには標準でWebSocket実装が乗っているのですが、今回は、socket.ioperl実装であるPocketIOを使っています。

あまりよく理解していなかったのですが、実際に実装をしてみるまで、WebSocket = socket.ioだと思っている僕がいました。 なので最初はMojoliciousに載っていたWebSocketを使って色々やってました。ただ、socket.ioはWebSocketが利用できないクライアントのときのフォールバック実装が豊富にあり、IE5.5からサポートしているという変態さを発揮しています。ヤバ過ぎる。Mojoliciousの方はそんなのなくて、WebSocket実装なくんばブラウザにあらず的な高慢さです。

いやまぁ、正直IE5.5とか…

とにかく、まぁ、そういうわけでMojoliciousのWebSocket実装から、PocketIOへと移行しました。そして、Mojolicious側とPocketIO側で同じセッション情報を利用したかったので、色々工夫したわけですよ。

yusukebeさんの「PocketIOのイカ娘語echoサンプル」がMojolicious + PocketIOなので参考になりそうです。が、正直perlはじめて1ヶ月経たない僕にはいろいろと理解ができず…。

特に、MojoliciousとPocketIOでどうやったらセッションを共有できるのか、また、よく色々なところに「Mojoliciousのセッション機能は貧弱だからPlackのSession使いましょう」とか書いてあるんだけど、そのPlackのセッションはどうやって使うのかと!

諦めムードですよ。まじで。

そんなわけで、だらだら前置き長いですが、 「MojoliciousとPocketIOを同じアプリケーションで作成しつつ、MojoX::Sessionを使ってセッションを共有する」 サンプルをgithubのリポジトリにおきました。

perl始めて2ヶ月だし、ほぼやっつけなのでソースコードの汚さはどうか!と先に言っておきます。 以下解説です。

Mojoliciousアプリを作る

まずは、普通に

mojo generate app [app_name]

としてアプリを生成します。次に自動生成のscript/app_nameとは別に、PSGIファイルを作ります。

PSGIファイルの書き方

mojolicio.usでPocketIOを使うためにはPSGIファイルを書き換えないといけないのですが、やりかたが分からず…。
CookBookよんでも、正直分からない…
こういうのみなさんどうやって身に付けてるんだろう。

とりあえず、色々なサイトぐぐって片っ端から色々やるに、script/[app_name].psgiファイルは次のようになりました。

script/mojolicious_pocket_io.psgi

MojoX::Sessionを使う

use utf8;
use warnings;
use strict;

use Mojo::Server::PSGI;
use Plack::Builder;
use Plack::App::File;
use PocketIO;

use FindBin;
use lib "$FindBin::Bin/../lib";

use MojoliciousPocketIO;
use MojoliciousPocketIO::WebSocket;

#MojoliciousをPSGIで
my $psgi = Mojo::Server::PSGI->new( app => MojoliciousPocketIO->new );
my $app = sub { $psgi->run(@_) };

#socket.ioのスクリプトとファイルのルートディレクトリ
my $siroot = "$FindBin::Bin/../public/js/";

builder {
  #socket.ioのスクリプトとファイルをmountする
  mount '/socket.io/socket.io.js' =>
    Plack::App::File->new(file => "$siroot/socket.io.js");
  mount '/socket.io/static/flashsocket/WebSocketMain.swf' =>
    Plack::App::File->new(file => "$siroot/WebSocketMain.swf");
  mount '/socket.io/static/flashsocket/WebSocketMainInsecure.swf' =>
    Plack::App::File->new(file => "$siroot/WebSocketMainInsecure.swf");

  #PocketIOをマウント
  mount '/socket.io' =>
    PocketIO->new( class => 'MojoliciousPocketIO::WebSocket', method => 'run' )\
;
  #Mojoliciousをマウント
  mount '/' => $app;
};

のような感じになりました。
知っている人にはなんでもないんでしょうが、mountっていうのでアプリケーションをパスを振り分けることができるんですね。
あと、PocketIOのサイトをいくら探してもsocket.io.jsとswfファイルを見つけることができなかったのですが、githubのsocket.io-clientにあるんですね。ものすごい探した…orz。

まぁ、これでMojolicio.usを使った普通のアプリケーションと、PocketIOを使ったWebSocketアプリケーションが両立できます。

で、問題はここから。
MojoliciousのセッションとPocketIOのセッションを透過的に扱いたい!
というわけで、MojoX::Sessionを使いました。Plack::MiddleWare::Sessionを使ってみたかったのですが、なんかよくわからなかったのでMojoX::Sessionです。一応Mojoliciousのプラグインもあるようなので。

Mojolicious側のセッションは次のように初期化しています。lib/MojoliciousPocketIO.pm

sub setup_app{
  my $self = shift;
  my $config =
    $self->plugin('Config',
                  {file => $self->app->home->rel_file('conf/app.conf')});
  $self->secret($config->{secret});
  $self->controller_class('MojoliciousPocketIO::Controller');
  
  my $handler = DBIx::Handler->new(
    $config->{db_dsn},
    $config->{db_username},
    $config->{db_password},
    +{
      mysql_auto_reconnect => 1,
      mysql_enable_utf8 => 1,
      RaiseError => 1,
      PrintError => 0,
      AutoCommit => 1,
      on_connect_do => [
        "SET NAMES 'utf8'",
        "SET CHARACTER SET 'utf8'",
      ],
    },
  );

  $self->plugin(
    session => {
      stash_key => 'mojox-session',
      store     => [dbi => {dbh => $handler->dbh}],
      transport => 'cookie',
      expires_delta => 1209600, #2 weeks.
      init      => sub{
        my ($self, $session) = @_;
        $session->load;
        if(!$session->sid){
          $session->create;
        }
      },
    }
  );
}

みたくなってます。Handlerの初期化とかは、なんかもっといい方法あると思います。で、WebSocket側は、

WebSocketController.pmソースコードがあります。Mojolicious側と大体同じ方法でMojoX::Sessionを初期化しています。
その際、session_idが必要なのですが、

WebSocket.pmでPocketIOのENVからHTTP_COOKIEを取り出し、SIDを取得しています。

sub session_id{
  my $self = shift;
  my $socket = shift;
  my $cookie = $socket->{conn}->{on_connect_args}[0]->{HTTP_COOKIE};
  if($cookie =~ /sid=([0-9a-f]+)/){
    return $1;
  }
  return undef;
}

SIDさえ取得できれば、

$session->load($self->session_id);

として、Mojolicious側のsessionを取得することができます。
いやしかし、$socket->{conn}->{on_connect_args}[0]->{HTTP_COOKIE};というのを発見するのに1日かかった感じです…orz

ここまでくれば、Mojolicious側のMain.pm

sub set_name{
  my $self = shift;
  my $name = $self->param('name');
  $self->session(name => $name);
  $self->render(json => $name);
}

みたいな感じで、sessionにセットしたnameを、

sub onEcho{
  my ($self, $socket, $message) = @_;
  my $room = $self->session('room_id');
  my $name = $self->session('name');
  $socket->sockets->in($room)->emit('echo', $name . ' says ' . $message);
}

のようにして、使うことができます。

分かっちゃえば楽だし、もう考えなくてすむのでいいんですがね…長かった。

サンプルを動かす

サンプルは

git clone https://github.com/kent013/MojoliciousPoketIO.git
cd MojoliciousPocketIO

リポジトリを取り出して、

cpanm --installdeps .

依存ライブラリをインストールし

twiggy script/mojolicious_pocket_io.psgi -l :3000

として、サーバを立ち上げた後、

http://localhost:3000

にアクセスすれば動作確認ができます。

しょかん

ショージキ、辛かった。
っていうか、この方法があっているのかも、分かりません!
もっとスマートな方法があったら教えてください!