高見知英の技術ログ

技術関係のログをQiitaから移行してきました。プログラミングのほか、使っているアプリの細かい仕様についてなど書いていきます。

Let's EncryptのルートCA期限切れに対応する

昨日から、Pythonで作ったプログラムのurllib.request.urlopenがなにやら以下のエラーで失敗するようになっていました。

<urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1129)>

なんでだろう と考えていて思い立って気付いた結果が、Let's EncryptのルートCA期限切れ。

jp.techcrunch.com

最近のブラウザなどは大抵問題ないようになっていますが、urllibモジュールは普通に影響を受けたようです。

(暫定的な?)解決策

とりあえず暫定的な対策としては、SSL証明書の有効期限切れを無視するようにすること。

import ssl

ssl._create_default_https_context = ssl._create_unverified_context

・・・

urlopenメソッドの前に、上記のコードを呼んでおけば問題ないです((引用先のコードを見ると一見urllib.requestimport前に呼び出す必要がありそうですが、そういうわけではないみたい))。

end0tknr.hateblo.jp

最終的にはたぶんモジュールの方で対策されたりするんじゃないかなあ と思いますが、それまではこれで。

既存WebアプリにServiceWorkerを追加してPWAっぽくする(たぶん続く)

とりあえず途中まで進んだので記録。

表題の通り既存のWebアプリにServiceWorkerを追加して、PWAっぽく使えるようにします。操作対象は最近いろいろいじっている、CastBackgroundです。今回はキャッシュ処理とmanifest.jsonの設定だけします。

github.com

実際どうやってるのかを見たい場合は、上記ソースコードを直接確認してください。manifest.jsonsw.jsを見れば大抵わかると思います*1

実際の手順

既存サイトにServiceWorkerを追加する場合、やるべきことは以下の通り

  1. (manifest.jsonを追加する)
  2. ServiceWorker用のJavaScriptファイルを「ルートフォルダに」作成する
  3. キャッシュ対象となるファイルのリストを作成する
  4. ServiceWorkerのライフサイクルに対応するイベントハンドラを記述する(最低限必要なのは、install, fetch。たぶんactivate)

とりあえず順を追って確認

(manifest.jsonを追加する)

ServiceWorkerとは直接関係ないですが、まずはmanifest.jsonをつくっておくと、アプリがPWAとして認識されるようになるので、作っておくと便利です。

この辺は参考文献のMDNを参考に。とりあえず記述の通りの内容を書いておけば問題はなさそうです。Edgeで開くと「Web app manifest should have the filename extension 'webmanifest'.」と言われていますがその辺はよくわからないのでとりあえずスキップ

アイコンファイルなどは事前に作成しておくと良いでしょう。

ServiceWorker用のJavaScriptファイルを「ルートフォルダに」作成する

まずは、ServiceWorkerを作成します。インストール処理については大まかにいろんなサイトに書いてある限りで問題なし。

  <script type="module">
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js', {scope: "/"})
        .then((reg) => {
          console.log('Service worker registered.', reg);
        });
    }
  </script>

type="module"はとりあえず流れで書いてしまっていますが要らないかも(外したときの挙動を確認していないので記載しています)。

第一引数にはServiceWorkerとして動作するためのJavaScriptファイルを指定します。

ServiceWorkerは、サイトの状態などを確認するためにブラウザにインストールされるUIを持たないJavaScriptファイルです。このファイルでオフライン時のキャッシュの制御などの操作を行います。

第二引数のscopeについては、第一引数のJavaScript配下のディレクトリパスを指定します。未指定時はJavaScriptファイルのあるディレクトとなります。

インストールが完了すると、以降scope配下のファイルリクエストがあったときに、このスクリプトが呼び出されるようになります。このため、よほどのことがなければアプリケーションのルートディレクトリ、すなわち/を指定することになります。

なお、ここでsw.jsがあるディレクトリより上位のディレクトリを指定すると例外が発生します*2

これは「Service Worker、はじめの一歩 | 第1回 Service Workerとは | CodeGrid」によると、「Service-Worker-Allowedヘッダ」というヘッダがないと変更できない動作のため、sw.jsは素直にルートディレクトリに置くのが無難です。

キャッシュ対象となるファイルのリストを作成する

次に、キャッシュしたいファイルの一覧を作成します。キャッシュ対象は自分の作成したファイルだけでなく、CDNなどを参照しているJQueryやBootstrapなどのファイルも対象です。

この辺については面倒なので、わたしは次のようなファイルを列挙してJavaScriptファイルを生成するPythonスクリプトを書きました。ファイルの変更があったときはこれを使用する ということで。

from pathlib import Path
import json

def files(rootdir: Path, path: Path) -> list[str]:
  l = []
  for f in path.glob("*"):
    if str(f.name).startswith("."): continue
    if f.is_file() and f.suffix == "": continue
    if f.is_file() and f.suffix in [".pptx", ".py", ".json", ".md"]: continue
    if f.is_dir() and f.name in ["tools"]: continue
    if f.is_file() or not f.name in ["res", "src"]:
      fs = "/{}{}".format(str(f.relative_to(rootdir)).replace('\\', '/'), '/' if f.is_dir() else '')
      print(fs)
      l.append(fs)
    if f.is_dir():
      l.extend(files(rootdir, f))
  return l

root = Path(__file__).parent.parent
data = json.dumps(files(root, root), indent=2)

with (root / "src" / "files.js").open("w") as f:
  f.write(f"""
const CB_ALL_FILES = {data}
""")

print("Done.")

そして、sw.jsの冒頭に以下のように書きます。

importScripts("./src/files.js")
const CB_ALL_FILES_URLS = CB_ALL_FILES.concat(
  "https://cdn.honokak.osaka/honoka/4.3.1/css/bootstrap.min.css",
  "https://code.jquery.com/jquery-3.5.1.slim.min.js",
  "https://cdn.honokak.osaka/honoka/4.3.1/js/bootstrap.bundle.min.js"
);

これでファイルリストの作成は完了です。*3

なお、詳細は不明ですが、このような記述と、JavaScript読み込み時のintegritycrossoriginの指定は競合を起こす場合があります*4

ServiceWorkerのライフサイクルに対応するイベントハンドラを記述する

sw.jsに各種イベントハンドラを書いていきます。ServiceWorkerのライフサイクルはGoogleの記事などを見ればおおよそわかります

とりあえず最低限キャッシュを動作させるには、installfetchだけ書けば良いっぽい(ただ適宜キャッシュを捨てるなどの処理におそらくactivateも必要)。

キャッシュが必要なファイルの一覧作成

installでキャッシュ必要なURLのリスト(https://からはじまるアドレスかサーバルートからの絶対パス)を指定します。

self.addEventListener('fetch', (event) => {
  console.log('service worker fetch ... ' + event.request);
  event.respondWith(
    caches.match(event.request).then((cacheResponse) => {
      return cacheResponse || fetch(event.request).then((response) => {
        return caches.open('v1').then((cache) => {
          cache.put(event.request, response.clone());
          return response;
        });
      });
    })
  );
});

上記のopen()メソッドの引数に使っている「v1」はキャッシュの呼称なので、本来であれば定数として持っておいた方が良さそうです。定数が変わればキャッシュを参照しなくなります。

ただしこの時点ではキャッシュが必要なURLのリストは指定できてもキャッシュは行なわれていない状態です。実際のキャッシュはfetchで行ないます。

fetchする

次に実際にファイルをダウンロードし、必要ならキャッシュに追加します。

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('v1').then((cache) => {
      return cache.addAll(
        CB_ALL_FILES
      );
    })
  );
});

これでキャッシュは完了です。

動作確認する

動作確認には、Edgeの開発者モードがとても便利です。Applicationタブでほとんどの情報を参照することができます。

f:id:TakamiChie:20210922174914p:plain
開発者モード

ただしくServiceWorkerがインストールされていれば、「ServiceWorkers」項目にsw.jsの名前が、「キャッシュストレージ」の項目にキャッシュされているファイルの一覧が表示されています(キャッシュストレージの項目に入っているはずのファイルがなかった場合は、files.jsの中身を確認します)。

オフライン時の挙動を確認する

オフライン時の挙動を確認するときは、画面上部のオフラインチェックボックスをチェックします。するとその間はインターネットに接続していない扱いになります。

f:id:TakamiChie:20210922175623p:plain
オフライン チェックボックス

ServiceWorkerを削除してやりなおす

キャッシュが古くて更新したいときなど、ServiceWorkerを削除するときは、「登録解除」ラベルをクリックします(実運用の時は、ちゃんとアンインストール処理を設けましょう)。

f:id:TakamiChie:20210922180020p:plain
登録解除ラベル

ただしそのまま再読込するとページがうまく読めないときがあるので、一度ブラウザタブを閉じてから開き直すと良いです。

(この時点での)まとめ

とりあえず落とし穴がところどころにあるのが注意が必要ですが、PWAのキャッシュストレージ機能を実装しました。実際使うにはキャッシュを適宜更新したり、期限切れキャッシュを削除したりする必要がありますが、ひとまず一つ目の峠は越えたようです。

参考文献

*1:執筆時点ではfeature-pwa_support_n32というブランチで作業中です

*2:Failed to register a ServiceWorker for scope ('指定しようとしたURL') with script ('sw.jsのファイルURL'): The path of the provided scope ('指定したディレクトリ') is not under the max scope allowed ('sw.jsのあるディレクトリ'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.

*3:なお、ディレクトリも必要であれば指定が必要です。たとえば多くのサーバーでは「/src/」がリクエストされた場合「/src/index.html」が返されますが、ServiceWorker側では相手のサーバが挙動をするサーバかどうかが判断できないからです。なお、今回は「/src/」に直接アクセスする可能性がないので記述していません

*4:実際に指定したファイルを読み込もうとすると「The FetchEvent for "https://code.jquery.com/jquery-3.5.1.slim.min.js" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors」というエラーが出ました。Githubで検索をしてみるとそれでも読めているソースコードが散見されるのでなにか手順が抜けている気もします

Bootstrapを呼び出すとき、bootstrap.bundle.min.jsを呼び出したらbootstrap.min.jsは不要

ずっとhonokak使ってたのでハマった。だってコード例ないんですもの。

表題の通りですが、bootstrap.bundle(.min).jsは、bootstrap(.min).jsを内包しているようです。そのため、bootstrap.bundle(.min).jsを読み込んだらbootstrap(.min).jsの読み込み処理は不要です。

なので以下のような書き方が正しい。

<html lang="ja">
<head>
  <!-- 中略 -->
  <link rel="stylesheet" href="https://cdn.honokak.osaka/honoka/4.3.1/css/bootstrap.min.css">
</head>
<body>

  <!-- 中略 -->
  <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js"></script>
  <script src="https://cdn.honokak.osaka/honoka/4.3.1/js/bootstrap.bundle.min.js"></script>
</body>
</html>

なお、Bootstrap公式サイトに書かれているとおり、bootstrap.bundle(.min).jsはpopper.jsを内包してはいますが、jqueryは内包していません。jqueryは別途読み込みましょう。

参考資料

pipenv installでエラーが出たら再起動を検討しよう

だいぶハマった。

久しぶりに触るプログラムでpipenv installpipenv updateをすると、次のようなエラーメッセージが出てくるときがあります。

ValueError: invalid literal for int() with base 10: ...

一番最後に出てくるエラーはUnicodeDecodeErrorだったりといろいろなプログラムエラーが出てくるんですが、コンソールを手繰ってみると上記のようなエラーメッセージが出てくる。

スタックトレースを見てみると自分が作ったソースコードに関するエラーが一切出ておらずどうしたものかと思っていたのですが、こういうときは大抵バックグラウンドで起動しているなんらかのプログラム名をpipenvが読み込んでしまっているために起こる問題っぽい。

タスクマネージャを開いて日本語名のプログラムが出ていたらそれを終了させる というのも一つの手なのですが、日本語名のプログラムが見当たらない場合はPCを再起動(多分一度サインアウトしてサインインし直すでも可)してしまうのも手。

また、同じようなエラーは拡張子.pyにPython以外の実行ファイルが関連付けられているときにも起こることがあるので、とりあえず以下の点を確認すると良いです。

  • 拡張子pyはちゃんとPythonに関連付けられているか?(うちの場合いつの間にかVisual Studio Codeに関連付いてた)
  • タスクマネージャの詳細タブで、日本語名のプログラムは起動していないか?
  • それでもダメならPCを再起動 またはサインアウト→サインイン

ああもうほんと疲れた。

参考資料

プルリクエストがマージされた話し

さてなんとなく他の方の記事を読んで「そういえば書いてないな」と思ったので。

neko314.hatenablog.com

元記事ほど大きなプロダクトではないですが、こちらでもPyPDF3に行なったプルリクエストがマージされました。

github.com

問題概要

内容は「PyPDF3*1で古いPDFを読むと、書き込み時に埋め込みフォントの情報が壊れる」というものです。

Shift-JISでフォント名が埋め込まれている非常に古いPDFを読み込んだときのみ発生する問題 ということで非常にレアなものですが、仕事で非常に古いPDFを読まなければいけなくなってしまい、やむなく・・・。

そもそもPDFにShift-JISでフォント名を埋め込む と言うこと自体が最近のPDFではあり得ない挙動のため、PdfFileReaderのコンストラクタ引数strictFalseを指定したときのみ、今回の動作を行なうようにしました。

まあ、結果こういう体験ができたわけで、古いPDFの存在にも多少は感謝しなければいけないのかもしれません。多少は。

テストも書いた

ちょうど良く現象を再現できるテストコードが作れたので、テストコードも書きました。

サンプルファイルは今からPDFを作るとどうやっても事象を再現できないため(Shift-JISでフォント名を書き込む ということができないため)、昔々開催していたスマートフォン懇親会の式次第チェックリストを使っています。

github.com

というかtempfileなんていうモジュールあったんですね。

一次出力ファイルの中身を確認することが困難になるため試行錯誤の多いテストケースではあまり使えませんが、まあ今回はそれほど試行錯誤するわけじゃないのでいいかなあ と。

英語を書いた

そして英語圏のプロダクトなので、もちろんプルリクエストも英語です。最近英語圏の企業さんやフォーラムに向けた英文を書く機会が増えてきました。

このあたりは表のブログにも書きましたが、慣れればわりと気軽に英文は送れます。多少面倒くさいですが、送れるには送れる。

blog.onpu-tamago.net

とくにShift-JISに起因する今回のような問題は海外の環境では再現しない(というか、そんなコンテンツがない)問題なので、どんどん問題挙げていくと自分のためにもちょうど良いかもよ と思った次第。

*1:2も4も5も同じ症状が出るのですが、いちおう今のところ一番活発そうなブランチが3だったので…

PyWebViewのURLパラメータにHTTPサーバを指定したときの挙動について

結局ソースコードを追ってデバッグ実行してなんとか対応できた・・・。

PyWebViewというPythonのモジュールを使うと、HTMLをPythonデスクトップアプリのGUIに使うことができます。

pywebview.flowrl.com

webview#create_window()というメソッドを使って生成したウィンドウにWindow#load_html()メソッドでHTMLを流し込めば、HTMLをGUIに使うことが可能です。

最近のバージョンでは、EdgeChromiumをデフォルトのレンダリングエンジンに使うようになった。

さて、電子書籍を作成するにあたり、PyWebViewを使っているこちらのアプリを久々にいじることになりまして。

github.com

とりあえずいつもの通りモジュールを最新化して、変更されたAPIにちょこちょこ対応しても、何故か動かない。なんでだろう?と思って調べてみたところ。

  • 最近のPyWebViewのバージョンでは、Windows上でのデフォルトレンダリングエンジンとして、mshtml(Trident)からEdgeChromium(Chromium) を使うようになった
  • mshtmlはドライブパスからはじまるフルパスを指定して、HTMLファイル内からCSSJavaScriptなどのリソースを読めたが、ChromiumEdgeではできなくなった(「Not allowed to load local resource」というエラーがブラウザのコンソールに表示される)

ということで、PyWebViewに備えられているもう一つの機能、「内部HTTPサーバを使う」という機能を使ってみることになりました。

PyWebViewで、内部HTTPサーバを使う

PyWebViewのマニュアルを読むと、webview#create_window()の第二引数(URL)にWSGI互換のHTTPサーバを指定すると、ページの処理時にそれを使うという機能があることが書かれています。

pywebview.flowrl.com

ページの使用例ではFlaskのHTTPサーバ機能を使っているようですが、もちろんWSGI互換のHTTPサーバであれば、指定が可能なはずです。

今回はFlaskを使うほどの大規模なことをするわけではないので、独自にHTTPサーバを作って処理しようと思ってやってみました。

ドキュメントに書かれていないこと

ここからがだいぶPyWebViewのドキュメントに書かれていないことで、ここで指定するHTTPサーバは、以下の条件を満たしている必要があります。

  • __webview_urlというアトリビュートが宣言されている必要がある(PyWebViewから値が書き込まれるだけなので中身はどんな値でも構わない)
  • __call__メソッドを実装している。

正直わたし自身WSGI互換のHTTPサーバというものがどういうものかよくわかっていないところもあるのですが(特定のクラスを継承しておく みたいなこともないようですし)、このへんを実際にデバッグ実行してみないと分からないという仕様はこのモジュールの厳しいところだなあと。

webview#create_window()の引数にHTTPサーバを指定してから起こること

webview#create_window()の引数にHTTPサーバを指定したあと、そのウィンドウが画面に表示されると、ただちにドキュメントルート(/)へのアクセスを指定して、__call__()メソッドが呼び出されます。

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    pass  # /// 処理 ///
  ###

このとき第一引数であるenvironには呼び出し時の環境変数のリストが(リクエストのURI(パス)は、environ["PATH_INFO"]に格納されています)、第二引数のstart_response()には、実際にレスポンス処理を行なうために呼び出す関数が格納されています。

ここでパスを解析してHTMLファイルなどのリソースを読み込み、返すには、次のようなコードを書けば良い。

import mimetypes

class Server:
  def __init__(self) -> None:
    self.__webview_url = ""
  ###
  def __call__(self, environ: typing.Dict, start_response: typing.Callable) -> typing.Any:
    path = environ["PATH_INFO"]
    filepath = self.findfilepath(path)
    content = None
    headers = []
    status = ''
    if not filepath is None and filepath.exists():
      filetype, encoding = mimetypes.guess_type(filepath)
      with open(filepath, "rb") as f:
        content = f.read()
        status = '200 OK'
        headers = [('Content-type', f'{filetype}; charset=utf-8' if filetype.startswith("text") else filetype)]
    else:
      status = '404 Not found'
      content= b'Not found'
    start_response(status, headers)
    return [content]

  def findfilepath(self, path: str) -> Path:
     pass # パスの解析処理…

mimetypesとは、Python標準モジュールの一つで、引数に指定したパスのMIMEタイプを返してくれるという便利なモジュールです。

ファイルの中身を見ないとか拡張子のないファイルに対応してないとか.jsをtext/plainとして返してしまうとかいろいろ問題はありますがとりあえずここで使うぶんには問題なし。

ここで作成したクラスをwebview#create_window()の第二引数で使用してやれば、とりあえずサーバとしての処理を行なうことが可能です。

なお、このメソッドは複数のスレッドから同時に呼び出されることになるため、複数行にまたがる処理(ログの出力など)は複数のスレッドの出力がバラバラになってしまう可能性があります。

デバッグ実行した場合も同じなので、ここのメソッドをデバッグ実行すると混乱します。あまりここでデバッグしないようにしましょう。

とりあえずこのへんを守っていれば、PyWebViewを使ったアプリケーションが作りやすくなると思います。

扱い方次第でかなり柔軟な表示もできるので、PyWebViewを使いこなすつもりであれば試してみても良いでしょう。

しかしこう、なんというか。Pythonのモジュールは後方互換性確保のためか知りませんがTypeHintがろくについてないものがおおいですね。内部APIをいじるときにどう書いたら良いか迷うので困ります…。

とくに今回みたいに、「詳細な挙動はjustMyCodeフラグをオフにしてVSCodeからデバッグしてみないと分からない」というのは正直つらいのです。

参考資料

成果物のリポジトリ

該当のコードはRe-VIEW-Preview/server.py at master · TakamiChie/Re-VIEW-Previewあたりにあります。

なおこのツール自体はRe:Viewの原稿をHTMLとしてプレビュー表示できるツールです。まだ安定版とはとても言えませんがRe:Viewを使っている方はこちらも見てみてください。

stand.fmの音声に独自でBGMを貼り付けるツールを作った

表題の通りですが、先日stand.fm用に録音した音声に、独自に作ったBGMを貼り付けるツールを作りました。

github.com

やってることはそんなに難しいことではなく…

  1. あらかじめBGMに使えるループ可能なBGMを用意する(1分程度)
  2. BGMと録音した音声をPythonのPydubモジュールで読み込む(pydub.AudioSegment#from_mp3())
  3. 録音の長さ+イントロ+アウトロぶんの長さ、BGMを敷き詰めた音声を作る(AudioSegment#__add__(AudioSegment))
  4. 3で作った音声のイントロ終了後~アウトロ開始直前までのボリュームを小さくする(AudioSegment#__add__(int), pydub.utils.ratio_to_db)
  5. ボリュームを小さくした部分に、録音した音声を合成する(AudioSegment#overlay())
  6. 5でできた音声をファイルに0000.mp3として保存する(AudioSegment#export())
  7. おまけに、元の録音音声に含まれるID3タグをすべてコピーする(mutagen.id3.ID3クラス)

という感じです。手順3の音声を作るときなど多少の計算が必要にはなりますが、とりあえずかなり簡単に音声ファイルをいじることができます*1

そもそもなぜそんなことをしたのか

stand.fmには、アプリ側で用意してあるBGMを設定する機能があります。なので、stand.fmにのみ音声を投稿する場合、BGM設定は不要です。

ただわたしは、stand.fmに公開した音声をAnchor.fmにも公開したい と思ったので、独自にBGMをつけることになりました*2

というわけで、公開したのが以下となります。音声聞いてみたいけどstand.fmアカウントないしなー と言う人や、ポッドキャスト愛好家でいちいちstand.fmで音声聞きたくない と言う人はどうぞ。

anchor.fm

作った感想

とりあえずPydubを使ってみて、ここまで簡単にできるのかー と思いました。ついでにBGMも幾つか作ったので日替わりBGMを実現することができました(BGMの作成には、LaunchpadというiPad用のアプリを使いました)。

ちゃんとフェードイン・フェードアウトやゲイン調整などちゃんとしたものを実装しようとするとそこそこに手間が掛かりそうですが、とりあえずここまではできるんだな と言うことがわかりました。

注意点

これといって注意といえる注意点はないのですが・・・。

強いて言うなら、Pydubは場面によってミリ秒単位で時間を扱うときと、秒単位で時間を扱うときがあるので、それを混同しないように気をつける必要があります*3

音声編集にも使えるかも

うちではstand.fmのほか、SBCast.やUdemy音声などの音声編集も行なっています。ゲインの調整などの定型処理が挟まることもあり、微妙に面倒だなあ と思っていました。

ただ、ひょっとするとこれはこのへんの分野にも使えるのかもしれないなあ と思いました。

今後Pydubを他でも使う機会があれば、また使ってみようかなあ と思います。

参考文献

*1:なお、mp3の読み込みにはFFMPEGが必要です。Windowsの場合Chocolateyで入れると楽です

*2:Anchor.fmにもBGMをつける機能はありますが、その機能を使うと双方で別のBGMがかかってしまうので

*3:音声を切り貼りするときはミリ秒、音声データの長さを処理するときは秒