Pythonのrequestsでログインが必要なページにアクセスする

タイトルにはログインと書きましたが要するにPythonのrequestsライブラリでセッションを扱う方法を書きます。

PythonでWebアクセスする場合、普通にURLにGETでアクセスして必要な情報が取れる場合だけでなく、事前にログインが必要なページの情報を取りたい場合があります。
requestsライブラリを使えばそれも比較的容易に実現できました。

基本的に、どのサイトでも同じような方法で対応可能ですが、例として今回はSBI証券のバックアップサイトで保有証券の一覧あたりを取って見ましょう。
バックアップサイトを使うのはHTMLがシンプルで見やすいからです。
ログイン画面のURLは https://k.sbisec.co.jp/bsite/visitor/top.do で、
ログイン後に取得したい保有証券一覧ページは、https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do です。

通常、ログイン等のセッションはCookieを使って維持されます。
そのため、素直に考えれば以下の手順でログイン済みのページにアクセスすることができます。

– ログインフォームのPOST先URL(htmlのformタグでaction属性で指定されているURL)に、フォームの入力内容のデータ(ユーザーIDやパスワード)をPOSTする。
– レスポンスのCookieを取得する。
– そのCookie情報をヘッダーに付与した状態でアクセスしたかったページにアクセスする。

あとは、アクセスするたびに、Cookieを付与してGETなりPOSTなりのリクエストを送れば、ブラウザで操作するのと同じように、ログイン後の画面にアクセスできます。

ただ、非常にありがたいことに、requestsライブラリには、Sessionっていう専用のクラスがあって、これを使うと逐一レスポンスからCookieを取り出したり、ヘッダーに付与したりするすることを意識せずにセッションを維持できます。

参考: Advanced Usage – Session Objects

使ってみる前に、POST先の情報や、フォームの各要素のnameを把握しておく必要があるので、まずはログインしたいページのフォームのHTMLを見ておきます。

import requests
from bs4 import BeautifulSoup


response_0 = requests.get("https://k.sbisec.co.jp/bsite/visitor/top.do")
soup_login = BeautifulSoup(response_0.text)
print(soup_login.find("form"))
"""
<form action="https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do" method="POST" name="form1">
<b>ユーザネーム</b>:
                <div style="margin-top:5px;">
<input istyle="3" maxlength="32" name="username" size="15" style="width:115px" type="text" value=""/>
</div>
<br/>
<b>パスワード</b>:
                <div style="margin-top:5px;">
<input class="bsite_pass" istyle="3" maxlength="30" name="password" size="15" type="password" value=""/>
</div>
<br/>
<div style="margin-top:5px;">
<a href="/bsite/info/policyList.do?list=attention">お取引注意事項</a>に同意の上、ログインして下さい
                </div>
<br/>
<div align="center">
<input name="login" type="submit" value=" ログイン "/>   
                <input name="cancel" onclick="dataclear()" type="button" value="キャンセル"/>
</div>
<br/>
</form>
"""

input タグのnameを見ると、username と password で良いようですね。

Sessionクラスを使う方法

さて、POST先ですが、actionに https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do と絶対URLで入ってるので、これをそのまま使えば良いでしょう。サイトやフォームによっては相対URLなので注意してください。
ではいよいよやってみましょう。

username と passwordは自分のを使います。

username = "{ユーザーネーム}"
password = "{パスワード}"
login_url = "https://k.sbisec.co.jp/bsite/visitor/loginUserCheck.do"

# セッションのインスタンスを作成する。
session = requests.Session()
response_1 = session.post(
        url=login_url,
        data={
            "username": username,
            "password": password,
        }
    )

print(response_1.text)
# 長いので出力略
# ログイン後に表示される機能メニューのHTMLが得られている。

はい、この段階でログインができました。そして、このsessionオブジェクトを使うと、目当ての保有証券一覧ページにアクセスできます。

response_2 = session.get(
        url="https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do"
    )
print(response_2.text)
# 長いのでこれも出力略

自分が持ってる株を公開するつもりもないので、出力完全に省略させていただきましたが、このページのHTMLをパースすると保有中の証券の一覧が得られます。

やっていることといえば、requests.get や requests.post の代わりに、最初にSessionオブジェクトを作って、それのgetやpostメソッドを使うというだけです。非常に手軽ですね。

おまけ: Sessionクラスを使わない場合どうするか

冒頭でCookieを付与して云々という手順を書いていますが、もちろんそのやり方でログインすることもできます。Sessionクラスを使うデメリットとか得なくて、自分でCookieの操作を実装する必要性無いと思うのですが、実は実験したのでそれ書いておきます。
次のようにすると、Session使わずにログイン後のページにアクセスできます。

# ログインフォームにデータをポストしログインする。
response_3 = requests.post(
        url=login_url,
        data={
            "username": username,
            "password": password,
        }
    )
# cookieを取り出す。
cookies = response_3.cookies

# cookieをヘッダーに付与してgetする。
response_4 = requests.get(
        url="https://k.sbisec.co.jp/bsite/member/acc/holdStockList.do",
        cookies=cookies,
    )

# 保有証券一覧ページのHTMLが得られる
print(response_4.text)

うまくいかなかった場合に検証すること

サイトによっては、ここまでの方法をそのまま適用してもうまくいかないことがあります。
だいたいその原因は何かしらのセキュリティ対策がなされているためです。
ユーザーエージェントの問題の場合もあると思いますし、サイトによっては、ユーザーIDやパスワードを入力する画面の表示時点でCookieを発行していて、それと整合性が取れない場合、不正な画面遷移としてログインさせてくれないこともあります。その場合は、Sessionのインスタンスを作って、そのインスタンスで一度ログイン画面をgetしてCookieを得ておく必要があるでしょう。

また、ログインフォームにhidden属性で非表示のinputタグを作っておき、そこにトークンのようなものを埋めておいてそれが無いとログインできないというケースもあります。
その場合は、HTMLから情報取り出して、それらのトークン情報をデータに加えてPOSTするなどの対応が必要になります。

ハードリンクとシンボリックリンク

新卒で就職したばかりの頃、UNIX/Linuxの研修で習ったような気がするけど忘れてしまっていた話を改めて調べたのでまとめておきます。
ちなみに、この記事はMacで検証しています。

概要説明

Windowsでは特定のファイルやフォルダーを別のフォルダーから開けるようにするショートカットという仕組みがあります。Macを含むUNIX系OSにも同様の仕組みがあり、それがリンクです。(正確には、ディスク上のファイルの実体と、ファイルパスを繋げる仕組みをリンクと言います。)

ここで、Windowsと違うのは、UNIX系のリンクにはハードリンクとシンボリックリンクの2種類がある点です。(実はWindowsでもハードリンクができるという話も聞きますが、あまり一般的ではないと思います。)

この後、実際にファイルを操作しながら具体的な挙動の違いを見ていきますが、ざくっと二つのリンクの違いを説明すると次のようになります。

ハードリンクは、一つの実体を持つファイルを表現するパスを複数作る方法です。
マニュアルにも、ハードリンクでは、リンクと元のファイルの区別はつかないと書かれています。
man ln から抜粋。
> A hard link to a file is indistinguishable from the original directory entry

それに対してシンボリックリンクは、リンク先のファイルへの参照を含みます。Windowsのショートカットに近いのはこちらです。

わかりにくいですね。

例えば、 file01.txt というファイルがあったとします。これは当然、ディスク上にあるデータが存在し、それに対して、file01.txt という名前でアクセスできることを意味します。

これに対して、file01.txt を file02.txtに「ハードリンク」したとします。すると、そのディスク上の同じデータが file02.txt というファイル名でもアクセスできるようになります。

一方で、file01.txt を file03.txt に「シンボリックリンク」したとします。すると、file03.txt は、file01.txt というパスへのリンクになります。その結果、file03.txtにアクセスしようとすると、file01.txtにアクセスすることになり、結局同じ実体のファイルにアクセスできることになります。

2種類のリンクの作成

まだわかりにくいのでやってみましょう。実際にファイルとリンクを作ってみます。
まず、サンプルのデータから。

# 検証用ディレクトリを作って移動
$ mkdir linktest
$ cd linktest
# サンプルデータ作成
$ echo "サンプルファイル1行目" > file01.txt
# 作成したファイルの情報を確認
$ ls -li
total 8
8795955 -rw-r--r--  1 {owner} {group}  34  6 12 17:51 file01.txt

ls コマンドで作ったデータを見るときに、-iオプションをつけてiノード番号も表示させました(先頭の8795955がそれ)。iノードというのは、ファイルの属性が記録されているデータのことで、iノード番号というのはそのデータについている番号のことです。このデータは実体のファイルと対応して存在しているので、実質的にディスク上のファイルの実体と対応している番号だと考えて大丈夫です。iノード番号が同じなら同じファイルを表しています。

さて、ここからリンクを作っていきましょう。リンクは ln コマンドで作ります。文法は次のとおりです。

# ハードリンクを作成する
$ ln リンク先 リンク名
# シンボリックリンクを作成する
$ ln -s リンク先 リンク名

やってみます。

# ハード/シンボリックでそれぞれリンクを作成する
$ ln file01.txt file02_hard.txt
$ ln -s file01.txt file03_symbolic.txt
# 確認
$ ls -li
total 16
8795955 -rw-r--r--  2 {owner} {group}  34  6 12 17:51 file01.txt
8795955 -rw-r--r--  2 {owner} {group}  34  6 12 17:51 file02_hard.txt
8796478 lrwxr-xr-x  1 {owner} {group}  10  6 12 18:03 file03_symbolic.txt -> file01.txt

はい、できました。

ls の結果を見ていきましょう。まずハードリンクの方(file02_hard.txt)です。
着目するべきは、最初のiノード番号で、元のファイルと全く同じになっています。これはfile02_hard.txtがfile01.txtと全く同じ実体ファイルにリンクしていること意味しており、同じデータに対して2個ファイル名があるような状態にになっています。ファイルサイズも同じ34バイトですね。
権限(-rw-r–r–) の後ろに 2 という数字がありますが、実はこれ、そのファイルへのリンク数です。元々1だったのがハードリンクを作成したことで2になっています。そして、ここからわかるのですが、シンボリックリンクの方はノーカウントです。(カウントされるなら3になるはず。) ちなみに、この数字がリンク数だっていう情報は、lsのマニュアル(man ls)のThe Long Format ってセクションに書かれています。

次は、シンボリックリンクの方(file03_symbolic.txt)を見ていきましょう。 -> で、元ファイルへのパスが書かれており、いかにもリンクって感じの表示になっていますね。
権限の最初にlがついて、lrwxr-xr-x となっていますが、このlはシンボリックリンクであることを示しています。そして、iノード番号は元のファイルと違うものが振られています。また、ファイルサイズも異なっていますね。file01.txtへの参照だけなのでサイズが小さいです。

当然ですが、全部同じデータを見てるので、中身を表示したら一致します。

$ cat file01.txt
サンプルファイル1行目
$ cat file02_hard.txt
サンプルファイル1行目
$ cat file03_symbolic.txt
サンプルファイル1行目

リンク元ファイルに変更が発生した場合の挙動

ここから、元ファイルに変更が発生した時のハードリンクとシンボリックリンクの挙動の違いを見ていきます。

まず、元ファイルに修正が入った場合、これはどちらのリンク方法でもそれぞれ反映されます。同じファイル見てるだけだからですね。1行追記してみてみましょう。

$ echo "サンプルファイル2行目" >> file01.txt
$ ls -li
total 16
8795955 -rw-r--r--  2 {owner} {group}  64  6 12 18:23 file01.txt
8795955 -rw-r--r--  2 {owner} {group}  64  6 12 18:23 file02_hard.txt
8796478 lrwxr-xr-x  1 {owner} {group}  10  6 12 18:03 file03_symbolic.txt -> file01.txt

# 念のため中身も見る
$ cat file01.txt
サンプルファイル1行目
サンプルファイル2行目
$ cat file02_hard.txt
サンプルファイル1行目
サンプルファイル2行目
$ cat file03_symbolic.txt
サンプルファイル1行目
サンプルファイル2行目

はい、 echo で追記したのはfile01.txtだけですが、3ファイルとも2行目が増えましたね。ファイルサイズが増えたのはfile01.txt/ file02_hard.txt だけで、file03_symbolic.txtはそのままなので想定通りです。ここまでは、ハードリンク、シンボリックリンク共に違いはありません。

ここからが差が発生することです。もし、このfile01.txtがリネームされたり削除されたりして、そのパスに存在しなくなったらどうなるでしょうか。
試しに消してみます。

$ rm file01.txt
# 1ファイル消えて2ファイル残っている。
$ ls -li
total 8
8795955 -rw-r--r--  1 {owner} {group}  64  6 12 18:23 file02_hard.txt
8796478 lrwxr-xr-x  1 {owner} {group}  10  6 12 18:03 file03_symbolic.txt -> file01.txt
# ハードリンクの方は元ファイルがなくなっていても開ける
$ cat file02_hard.txt
サンプルファイル1行目
サンプルファイル2行目
# シンボリックリンクの方はリンク先ファイルがなくなると開けない
$ cat file03_symbolic.txt
cat: file03_symbolic.txt: No such file or directory

はい、ここで大きな差が生じました。元ファイルが消えてしまったのですが、ハードリンクのファイルは、元ファイルのパスと関係なくディスク上のファイルの実体にリンクされていたので元ファイルのパスが消えてしまっても問題なく開くことができます。(消したと思ったファイルが残ってる、というのがデメリットになるケースもありそうですが。)
ちなみに、ls -li の結果のリンク数は1個になっていますね。

一方で、シンボリックリンクの方は、元ファイルへの参照しか情報を持っていなかったので、元ファイルのパスがなくなってしまうとデータにアクセスができなくなってしまっています。

さて、次はその逆の操作です。元々存在してたファイルと同じパスで、再度ファイルが作成されたらどうなるでしょうか。この時の挙動もそれぞれ異なります。やってみましょう。

# あたらめてリンク先ファイル作成
$ echo "新規作成したファイル1行目" > file01.txt
$ ls -li
total 16
8798109 -rw-r--r--  1 {owner} {group}  38  6 12 18:37 file01.txt
8795955 -rw-r--r--  1 {owner} {group}  64  6 12 18:23 file02_hard.txt
8796478 lrwxr-xr-x  1 {owner} {group}  10  6 12 18:03 file03_symbolic.txt -> file01.txt

新しい file01.txt は 元のと異なるiノード番号で作成されましたね。これは要するに、ファイル名は同じだけど実データとしては元々存在してたfile01.txtとは異なるファイルであることを意味します。そいて、よく見ていただきたいのは、file01.txt と file02_hard.txt のファイルサイズが違うことです。もはや同じファイルは指し示しておらず、file02_hard.txtは元々のファイルにリンクされていますね。それぞれ開くと明らかです。

$ cat file01.txt
新規作成したファイル1行目
$ cat file02_hard.txt
サンプルファイル1行目
サンプルファイル2行目
$ cat file03_symbolic.txt
新規作成したファイル1行目

はい、ハードリンクしていたfile02_hard.txtはもう完全にfile01.txtとは別ファイルになってしまいました。
一方で、リンク先がなくなって開けなくなっていたシンボリックリンクの方(file03_symbolic.txt)は、同じパスのファイルができたら自動的にそこにリンクされてfile01.txtと同じデータが参照できるようになりました。

ハードリンク、シンボリックリンクのどちらを選ぶかは、これらの挙動を踏まえて決めるのが良いと思います。

その他の違い

以上で元ファイルの編集に関するリンクごとの挙動の違いを書いて来ましたが、他にも少し違いがありますのでまとめておきます。

まず、ファイルではなくディレクトリに対しては、ハードリンクは作成できず、シンボリックリンクのみ作成できます。

# 実験用ディレクトリ作成
$ mkdir subfolder01

# ハードリンクは作れない
$ ln subfolder01 subfolder02
ln: subfolder01: Is a directory

# シンボリックリンクは作れる
$ ln -s subfolder01 subfolder02
$ ls -li
8799037 drwxr-xr-x  2 {owner} {group}  64  6 12 18:46 subfolder01
8799038 lrwxr-xr-x  1 {owner} {group}  11  6 12 18:46 subfolder02 -> subfolder01

また、別の違いとして、ハードリンクはパーティションなどファイルシステムを跨いで作ることはできないというものもあります。ハードリンクを作れるのは同じパーティション内のみだけです。理由はiノード番号の管理が違うからだそうです。
一方でシンボリックリンクはどこでも作れます。

あとは、ハードリンク(元のファイルパスも含む)を全部消すと、ファイル自体が削除されてしまいますが、シンボリックリンクは消しても元のファイルに影響がないとか細々した違いがあります。

ハードリンクしているファイルを探す方法

ls コマンドでそのファイルへのリンク数が表示されますが、 どこからリンクされているか探したくなることはあると思います。そのファイルを確実に消したい時などは一通り洗い出す必要ありますし。

良い探し方を調べていたのですが、今のところ、find コマンドで iノード番号を調べて検索する以外になさそうです。-inum 引数で指定できます。

今やっているサンプルは同じディレクトリ配下なので楽勝ですが、遠いパスに作っていたらかなり広範囲をfindで探さないといけないですね。

# 実験のためハードリンクを作成する 
$ ln file01.txt file04.txt
# iノード番号を調べる
$ ls -i file01.txt
8798109 file01.txt
# find コマンドで該当のiノード番号を持つファイルを探す
$ find . -inum 8798109
./file04.txt
./file01.txt

余談: 何が発端でこれを調べていたのか

なんで今になってこんなのを調べているのかというと、実は個人的に開発してるプロジェクトがあって、そのコード管理に使いたかったからです。

ほとんどのソースコードはプロジェクトのディレクトリ配下に格納されていてそこでgit管理されているのですが、ごく一部/etc/の配下とか、ホームディレクトリのドット付き隠しフォルダの下とかで作成する必要上がります。

これらをどうやって管理しようかなと思ったときに、プロジェクトのディレクトリ内に実体ファイルを作って、それらの本来の配置場所にリンクを貼れば単一のリポジトリで管理できるじゃないかと思いつきました。で、その時のリンク方法が2種類あったのでどっちがいいのかというのが発端になります。

この用途だと、git管理してるファイルと、稼働してるファイルを確実に同期させたいのでシンボリックリンクの方が良さそうですね。

まぁ、その他、brewで入れたソフトウェアとかMeCabの辞書のファイルたちとか自分の環境内でシンボリックリンクで稼働しているファイルはいろいろ存在し、これらについても理解を深められたのは良かったです。

Lambda の関数URLで送信されたデータを扱う

前回に引き続き、Lambdaの関数URLの話です。URLでLambdaを起動できるのは便利ですが、せっかくならアクセスするときに何かしらの情報を渡して処理を変えたいということは多くあると思います。その場合に、あり得るパターンの数だけLambda関数と個別のURLを用意しておくというのは現実的ではありません。
普通は、世の中のたいていのAPIがやっているように、クエリパラメーターや、POSTされたデータに応じて挙動を変える作りにします。

受け取った値に応じて挙動を変えるっていうのは、ただのPythonのコーディングの話なので、今回の記事では、どうやってクエリパラメーターとかPOSTされたデータを受け取るかって部分を扱います。これまでAPI GatewayでAPI作ってたような人にとっては常識的なことしか書いてないと思いますがご了承ください。(僕はWebエンジニアではなく、これまでLambdaは手動かスケジュール実行で使って来たのでこの辺の挙動に詳しくないのです。)

さて、前置きが長くなって来ましたが、実はタネは非常に簡単で、Lamda関数の定義にデフォルトで入っている引数「event」、これが今回の主役です。

def lambda_handler(event, context):  # この第一引数eventに欲しいデータが渡される
    # 処理の中身

関数URLからLambdaが呼び出されたとき、lambda_handlerの第一引数には、以下のドキュメントのリクエストペイロードと呼ばれているデータが渡され、関数中ではeventという変数名で扱えます。
参考: Lambda 関数 URL の呼び出し – AWS Lambda の リクエストペイロードの形式 の部分

以下のような関数を作って実際に動かしてみるのが一番わかりやすいと思います。
テキストエリア2個と送信ボタンを持つフォームをGET/POSTでそれぞれ作成し、event変数の中身を表示します。

import pprint


def lambda_handler(event, context):
    html = """
<!DOCTYPE html>
<h2>GETのフォーム</h2>
<form action="/" method="get">
    <input type="text" name="text1"><br>
    <input type="text" name="text2"><br>
    <input type="submit">
</form>
<h2>POSTのフォーム</h2>
<form action="/" method="post">
    <input type="text" name="text3"><br>
    <input type="text" name="text4"><br>
    <input type="submit">
</form>
    """

    html +="<h2>eventの内容</h2>\n"
    html += "<pre>" + pprint.pformat(event, compact=True) + "</pre>"

    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        'statusCode': 200,
        "body": html,
    }

上記のコードで関数を作成し、関数URLからアクセスすると、フォーム二つとpprintで成形されたeventの辞書データが表示されます。また、それぞのフォームに値を入れて送信すると、eventのどこに結果が入るかが確認できます。
(キャプチャとか貼るとわかりやすいと思うのですが、idっぽいものが大量にあるので省略させていただきます。上のコードを試してただくのが一番良いです。)

まず、メソッド(GET/POSTなど)の区別ですが、次の部分にあります。
Pythonで言えば、 event[“requestContext”][“http”][“method”] で取得できますね。

{
    # 略
    'requestContext': {
        # 略
        'http': {
            # 略
            'method': 'GET',
        }
    }
}

それでは、 GET/ POST 順番にフォームを実行して、どのように値が取れるか見て行きましょう。
フォームには以下の値を入れて送信します。
1つ目のテキスト: あ&い=う%え,お
2つ目のテキスト: abc?123

フォームからGETで送信する場合、URLの末尾に入力内容が添付され、次のURLにアクセスされます。特殊文字はパーセントエンコーディングされていますね。

https://{url-id}.lambda-url.{region}.on.aws/?text1=あ%26い%3Dう%25え%2Cお&text2=abc%3F123

eventの中ではevent[“rawQueryString”] と event[“queryStringParameters”]の部分に現れます。
クエリ文字列がないときは、rawQueryStringは空文字””ですが、queryStringParametersの方はキー自体が存在しないので、コードで使うときは注意してください。

{
    # 略
    'queryStringParameters': {'text1': 'あ&い=う%え,お', 'text2': 'abc?123'},
    # 略
    'rawQueryString': 'text1=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text2=abc%3F123',
    # 略
}

上記の内容で分かる通り、rawQueryStringの方はURIエンコーディングされていますが、queryStringParametersの方は使いやすいように辞書型にパースしてURIエンコードも元に戻してくれています。こちらを使って行きましょう。
フォームに入れた値がURLに載ってしまうというのは大きなデメリットですが、ぶっちゃけると単純にテキスト等を送るフォームならPOSTよりGETの方が渡した値使いやすいな、と感じています。(Web系開発素人の発想ですが。)

続いて、POSTの場合です。個人的にはこの種のフォームは普通はPOSTで使うものだと思っています。
POSTの場合は、GETと違って少しわかりにくく、eventを表示しても送ったデータがそのままでは見つかりません。ではどこにあるのかというと、実は event[“body”]に、base64エンコードされて入ってます。これはフォームからPOSTした場合の挙動です。curl等でテキストのままポストしてあげればそのまま表示されます。
base64の判定は、event[“isBase64Encoded”] で行います。

{
    # 略
    'body': 'dGV4dDM9JUUzJTgxJTgyJTI2JUUzJTgxJTg0JTNEJUUzJTgxJTg2JTI1JUUzJTgxJTg4JTJDJUUzJTgxJThBJnRleHQ0PWFiYyUzRjEyMw==',
    # 略
    'isBase64Encoded': True,
    # 略
}

base64なので自分でこれをデコードする必要があります。
参考: PythonでBase64エンコードとデコード

該当部分のコードだけ書くとこんな感じです。

import base64

base64.b64decode(event["body"]).decode()
# text3=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text4=abc%3F123

パーセントエンコードされていますね。これを以下の手順で処理する必要があります。
&で区切って、フォームの各要素ごとの値に分割する。
=で区切ってキーと値のペアに変える。
パーセントエンコードをデコードする。

自分でやるのは面倒なので、ライブラリ使いましょう。
参考: PythonでURL文字列を要素に分解する

次のようになります。

import base64
from urllib.parse import parse_qs


parse_qs(base64.b64decode(event["body"]).decode())
# {'text3': ['あ&い=う%え,お'], 'text4': ['abc?123']}

キー対値 の辞書ではなく、 キー対値の配列 の辞書が結果として得られるので注意が必要です。特殊文字たちも元の形に戻っていますね。

さて、以上で関数URLにクエリストリングに付加されたデータや、POSTされて来たデータをLambdaの関数で取り出せるようになりました。

あとはこれを受け取ってそれに応じた処理をする関数を作るだけで、柔軟な処理を行えるようになると思います。

AWS Lambda Function URLs を試してみた

発表されてから少し時間が経ってしまったので、今更感が出ていますが AWS Lambdaの新機能である Function URLs (関数URL) を試してみました。
参考: AWS Lambda Function URLs の提供開始: 単一機能のマイクロサービス向けの組み込み HTTPS エンドポイント

これ何かと言うと、AWSのLambda に API Gateway を使わずに、Lambda内部の機能だけでURLを発行して、そのURLにアクセスするだけで関数を実行できると言うものです。

ドキュメントはこちらかな。
参考: Lambda 関数 URL

ちょっとお試しで動かしてみましょう。関数URLが有効な新しい関数を作る手順は以下の通りです。

1. Lambdaのコンソールにアクセス。
2. 関数の作成をクリック。
3. 関数名/ ランタイム(NodeやPythonのバージョン)/ アーキテクチャ などを入力。
4. アクセス権限でロールの設定。
5. ▼詳細設定のメニューを開く。ここに「関数URLを有効化」が新登場しています。
6. 「関数URLを有効化」にチェックを入れる。
7. 認証タイプ を選択。お試しなので、一旦NONEにしてパブリックにしました。
8. 関数の作成をクリック。

これで関数が出来上がります。デフォルトでは以下のコードが用意されています。

import json

def lambda_handler(event, context):
    # TODO implement
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

ここで、コードソースのウィンドウの上の、関数の概要ウィンドウの右側に、関数URLというのが作られ、以下の形式のURLが用意されているのがわかります。
この時点でURL用意されているんですね。

https://{url-id}.lambda-url.{region}.on.aws/

ドキュメントによると、url-id 部分からわかる人はその人のアカウントIDが取得できるそうです。そのため、僕のサンプルで作った関数URLがお見せできませんが、たったこれだけの手順で試せるので是非やってみてください。

このURLを開くと、”Hello from Lambda” の文字が表示されます。curlでも良いです。

$ curl https://{url-id}.lambda-url.ap-northeast-1.on.aws/
"Hello from Lambda!"

折角ブラウザで開けるので、HTMLを表示してみましょう。
ここで注意しないといけないのは、単純に”body”にHTMLの文字列を書くだけだと、JSONとして扱われて、ブラウザがHTMLソースをレンダリングせずにそのまま表示してしまいます。

それを防ぐためには、Content-Type を “text/html” に指定してあげる必要があります。
指定は、ドキュメントのこちらのページの、「レスポンスペイロードの形式」というセクションに沿って行います。
参考: Lambda 関数 URL の呼び出し – AWS Lambda

{
   "statusCode": 201,
    "headers": {
        "Content-Type": "application/json",
        "My-Custom-Header": "Custom Value"
    },
    "body": "{ \"message\": \"Hello, world!\" }",
    "cookies": [
        "Cookie_1=Value1; Expires=21 Oct 2021 07:48 GMT",
        "Cookie_2=Value2; Max-Age=78000"
    ],
    "isBase64Encoded": false
}

Content-Type の デフォルトは “application/json” ですね。

このブログへのリンクでも表示してみましょうか。
コードを以下の内容に書き換えて、 Deploy をクリックします。

def lambda_handler(event, context):
    html = """<!DOCTYPE html>
<a href="https://analytics-note.xyz/" target="brank_">分析ノート</a>"""

    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        "statusCode": 200,
        "body": html
    }

これで、関数URLにアクセスすると、このブログへのリンクだけの簡素なページが表示されます。

Content-Type を “text/html” ではなく、 “text/html;charset=utf-8” にしているのは、こうしないと日本語が文字化けするからです。

ここまで、新規の関数について関数URLを設定する方法を書いて来ましたが、既存の関数についても「設定」のところに関数URLの項目が追加されており、非常に簡単に浸かるようになっています。

こうしてURLを簡単に使えると、たとえばEC2の起動/停止などの処理も今までboto3のバッチでやっていましたが、ブラウザでアクセスするだけで起動できるようになります。
jupyter notebook のサーバーを操作するも一層手軽になりそうです。
(端末のコンソール起動する一手間がなくなるだけですが、地味に面倒だったので)

ただ、気をつけないといけないのはグローバルにアクセスできるので、誰でも実行できるという点ですね。もしクリティカルな処理で使う場合は、何かしらの制限をかけた方が良いでしょう。

Pandasのデータが単調増加/単調減少かどうかを判定する

最近、とあるデータを分析する前のデータの品質チェックで、ある値の列が単調増加になっているかどうかを判定する必要が発生しました。全グループ単調増加のはずのデータだったのに、そうなっていないのが見つかったため、一通りチェックすることにしたのです。

Pandasのデータだったので、普通に差分をとって全部正であることを見ればよかったのですが、もっとスマートな方法がないか探してみたところ実際に良い方法が見つかったのでその紹介です。ちなみに差分の取り方は過去に記事にしています。
参考: Dataframeの差分を取る

さて、今回見つけたのは、PandasのSeries(とindexオブジェクトも)に用意されている、以下のプロパティです。(3個目のやつは1個目のエイリアスなので実質2個ですが)
pandas.Series.is_monotonic
pandas.Series.is_monotonic_decreasing
pandas.Series.is_monotonic_increasing (is_monotonicのエイリアス)

is_monotonic と is_monotonic_increasing はそのSeriesが単調増加ならTrue、そうでないならFalse、is_monotonic_decreasing はそのSeriesが単調現象ならTrue、そうでないならばFalseを返してくれます。

いちいち差分取って確認したりしなくても、最初からフラグを持っていたというとてもありがたい話でした。

注意しないといけなのは、count()やsum()などのメソッドではなくプロパティなので使う時にカッコは要らないのと、当然引数が渡せないので細かな調整などはできず用意された仕様でそのまま使うしかないということでしょうか。(メソッドにしていただいて、狭義/広義やNoneの扱いなどが調整できるともっと便利だったのですが。)

とりあえず使ってみましょう。サンプルは全部 is_monotonic (単調増加)でやってみます。
単調減少の場合は、is_monotonic_decreasingをつかうだけで基本的には同じです。

まず普通に単調増加の時とそうでないときで、True/ Falseが変わっているのをみます。
下の例で分かる通り、判定は広義の単調増加の場合であってもTrueです。

import pandas as pd


# (広義)単調増加の場合
sr = pd.Series([1, 1, 2, 3, 5])
print(sr.is_monotonic)
# True

# (狭義)単調増加の場合
sr = pd.Series([1, 2, 4, 9, 16])
print(sr.is_monotonic)
# True

# 単調増加ではない場合
sr = pd.Series([1, -2, 3, -4, 5])
print(sr.is_monotonic)
# False

Noneなどが入ってる場合はそれ以外の部分が単調増加でもFalseになるようです。

sr = pd.Series([None, 1, 2, 3, 5])
print(sr.is_monotonic)
# False

値が数値ではなく文字列の場合も使えます。大小が定義できるものなら良いようです。

sr = pd.Series(["abc", "lmn", "xyz"])
print(sr.is_monotonic)
# True

sr = pd.Series(["z", "y", "x"])
print(sr.is_monotonic)
# False

広義ではなく狭義単調増加の判定がしたいんだということもあると思うのですが、上の方でも書いた通り、is_monotonicはプロパティなのでそう言った細かい調整はできません。どうやったらスマートに判定できるのかなと、考えたのですが、まず is_monotonic で単調増加性を判定した後に、値のユニークカウントと値の数を比較するのがいいのではないかと思いました。狭義単調増加であれば全て異なる値になっているはずだからです。

# (広義)単調増加の場合
sr = pd.Series([1, 1, 2, 3, 5])
print(sr.is_monotonic)
# True
# 重複する値があるのでFalseになる
print(sr.nunique() == sr.count())
# False

# (狭義)単調増加の場合
sr = pd.Series([1, 2, 4, 9, 16])
print(sr.is_monotonic)
# True
# 重複する値がないのでTrueになる
print(sr.nunique() == sr.count())
# True

さて、ここまで Seriesについて書いて来ましたが、DataFrameのデータについて調べたい場合もあります。というか、単一のSeriesについて調べるのであれば差分取って調べてもそんなに手間ではなく、僕が今回この方法を調査してたのはデータフレームで大量のデータについて調査する必要があったからです。

データの持ち方について、普通のパターンがありうるのでまずは列別にその列全体が単調増加かどうかを調べる方法を紹介します。サンプルデータはこちらです。

df = pd.DataFrame(
    {
        "year": [2010, 2011, 2012, 2013, 2014],
        "col1": [1, 1, 2, 3, 5],
        "col2": [1, 2, 4, 9, 16],
        "col3": [1, -2, 3, -4, 5],
    }
)
print(df)
"""
   year  col1  col2  col3
0  2010     1     1     1
1  2011     1     2    -2
2  2012     2     4     3
3  2013     3     9    -4
4  2014     5    16     5
""" 

PandasのDataFrameにはis_monotonicなどのプロパティはありません。df.is_monotonicとかしても、各列について一括で調査したりはできないようです。
(これがメソッドであるsum()やcount()との大きな違いですね。)
for文を回して列ごとにis_monotonicをやってもいいのですが、 applyを使うのがスマートではないでしょうか。以下のようにして、col3だけ単調増加ではないことがすぐわかりました。

print(df.apply(lambda col: col.is_monotonic))
"""
year     True
col1     True
col2     True
col3    False
dtype: bool
"""

次に、別のデータの持ち方をしているDataFrameを見てみましょう。これは、値は1列に全部入ってるのですが、カテゴリーを示す列があり、カテゴリーごとに単調増加かどうかを判定したいというケースです。こういうやつです。

df = pd.DataFrame(
    {
        "type": ["type1"] * 5 + ["type2"] * 5 + ["type3"]*5,
        "year": [2010, 2011, 2012, 2013, 2014] * 3,
        "value": [1, 1, 2, 3, 5] + [1, 2, 4, 9, 16] + [1, -2, 3, -4, 5],
    }
)
print(df)
"""
type  year  value
0   type1  2010      1
1   type1  2011      1
2   type1  2012      2
3   type1  2013      3
4   type1  2014      5
5   type2  2010      1
6   type2  2011      2
7   type2  2012      4
8   type2  2013      9
9   type2  2014     16
10  type3  2010      1
11  type3  2011     -2
12  type3  2012      3
13  type3  2013     -4
14  type3  2014      5
"""

これもtypeが3種類くらいで、値も全部で15個みたいな上のサンプルのようなやつならfor文で回してもそんなに大変ではないですが、データが莫大になるとスマートな方法が欲しくなります。いろいろ考えたのですが、素直にgroupbyで分割して、applyでlambda式を当てていくのが良いと思います。何度も書いていますが、メソッドではないので、groupby(“type”).is_monotonic() のような書き方では動きません。

print(df.groupby("type").apply(lambda x: x.value.is_monotonic))
"""
type
type1     True
type2     True
type3    False
dtype: bool
"""

以上が、is_monotonic の使い方や応用例の紹介になります。単調増加とか単調減少の判定をしたいって場面は多くないかもしれませんが、いざ必要になるとこれらのプロパティは非常に便利で、元のデータがリストやNumpyのArrayだった場合はこれのためにわざわざSeriesに変換してもいいのではないかと思うレベルです。機会があればこれらの存在を思い出してください。

Pythonで時刻をUNIX時間に変換する方法やPandasのデータを使う時の注意点

UNIX時間(または、エポック秒、UNIX時刻)というのは、UTCの1970年1月1日0時0分0秒を0として、そこからの経過秒数を基準に時間を表そうという方法です。
参考: UNIX時間 – Wikipedia

普段使っているトレジャーデータがログの時刻をUNIX時間で記録しているので、僕は業務で目にすることが多いのですが、基本的にSQLのUDFで文字列に変換して抽出するようにしているので、普段のPythonのプログラムで扱うことはあまりありません。しかし、最近とあるデータをPythonで処理していた時、文字列の時刻とUNIXタイムの変換をする機会があったので方法と注意点をまとめておきます。

注意点というのは、dateteimeモジュールを使うケースと、pandasを使うケースでタイムゾーンに絡む挙動が少々違い、危うく間違った変換をしそうになったのです。

コード例ごとに時刻が違うとこの記事が読みにくくなるのでこの記事では、
日本時間の 2022-05-20 15:00:00 を使います。
時差が9時間あるので、UTCでは 2022-05-20 6:00:00 であり、
UNIX時刻は 1653026400 です。

それでは、具体的な変換方法を見ていきましょう。

標準の datetime ライブラリを用いた変換

参考: datetime — 基本的な日付型および時間型 — Python 3.10.4 ドキュメント

まず、 time スタンプから時刻に変換するには fromtimestamp というメソッドを使います。

import datetime


sample_time = datetime.datetime.fromtimestamp(1653026400)
sample_time  # 2022-05-20 15:00:00 を示す datetimeオブジェクトができる。
# datetime.datetime(2022, 5, 20, 15, 0)

# printすると文字列になる
print(sample_time)
# 2022-05-20 15:00:00

# 表示形式を調整したい場合は strftime
print(sample_time.strftime("%Y年%m月%d日 %H時%M分%S秒"))
# 2022年05月20日 15時00分00秒

ここで注目すべきは、fromtimestamp が勝手に日本時間で変換してくれている点です。
ドキュメントのfromtimestampにも、「オプションの引数 tz が None であるか、指定されていない場合、タイムスタンプはプラットフォームのローカルな日付および時刻に変換され、」と書いてあります。非常にありがたいですね。

明示的に日本時間(+9時間)であることを指定するにはタイムゾーンの情報も付加します。

tzinfo=datetime.timezone(datetime.timedelta(hours=9))
datetime.datetime.fromtimestamp(1653026400, tz=tzinfo)
# datetime.datetime(2022, 5, 20, 15, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400)))

今度は逆に、”2022-05-20 15:00:00″ という文字列をタイムスタンプにしてみます。
これは、strptime() でdatetime型のオブジェクトに変換し、timestamp()メソッドを呼び出せば良いです。

# 指定時刻のdatetimeオブジェクトを作る
sample_time = datetime.datetime.strptime("2022-05-20 15:00:00", "%Y-%m-%d %H:%M:%S")
sample_time
# datetime.datetime(2022, 5, 20, 15, 0)

# timestamp() メソッドでUNIX時間に変換できる
print(sample_time.timestamp())
# 1653026400.0

正しく変換されましたね。

ちなみに、元のデータが文字列ではなく,datetimeメソッドで作ったdatetimeオブジェクトでも結果は同様です。ちゃんと日本時間として変換してくれます。

print(datetime.datetime(2022, 5, 20, 15, 0, 0).timestamp())
# 1653026400.0

さて、ここまでは標準のdatetimeオブジェクトにおける挙動でした。
端末の環境が日本時間なら、あまりタイムゾーンを意識しなくても正しく動きます。逆にいうと、AWSなどのクラウドサービスを海外リージョンで使っている場合などは環境の時刻設定に気を付けて使う必要があるということです。
次はPandasで見ていきます。

Pandasのデータにおける変換

データ分析の仕事しているため、一個の時刻情報を変換するということはあまりなく、たいていは大量のテーブルデータの一列を丸ごと変換する必要があります。そういう時は、datetimeオブジェクトではなく、Pandasを使います。

早速、僕がちょっとハマったところを共有します。以下のようなデータがあったとします。この時点で、time_str列は文字列です。

df = pd.DataFrame({
    "key": ["key1", "key2", "key3"],
    "time_str": ["2022-05-20 15:00:00", "2022-05-20 15:00:00", "2022-05-20 15:00:00"]
})
print(df)
"""
    key             time_str
0  key1  2022-05-20 15:00:00
1  key2  2022-05-20 15:00:00
2  key3  2022-05-20 15:00:00
"""

UNIX時刻に変換する準備として文字列を時刻(datetime)型に変換するために、pd.to_datetimeを使います。(細かく指定しなくてもいい感じに日時として解釈してくれる非常に便利な関数です。)

df["time"] = pd.to_datetime(df.time_str)
print(df["time"])
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
Name: time, dtype: datetime64[ns]
"""

ここから、dt.timestamp() みたいなメソッドで変換できると楽なのですが、dtにはtimestamp()がありません。しかし、datetime64の各要素はtimestamp()メソッドを持っているので一見これで変換できるように見えます。

df.time.apply(lambda t: t.timestamp())
"""
0    1.653059e+09
1    1.653059e+09
2    1.653059e+09
Name: time, dtype: float64
"""

floatになるので、整数への変換もやりましょう。

df.time.apply(lambda t: int(t.timestamp()))
"""
0    1653058800
1    1653058800
2    1653058800
Name: time, dtype: int64
"""

はい、できました、と思ってしまいますがよく見ると結果が違いますよね。

$1653058800 – 1653026400 = 9 * 60 * 60$ なので、ちょうど9時間分ずれた結果になってしまいました。要するに、pd.to_datetime は 与えられた時刻をUTCで解釈しているわけです。

以下の二つが違う結果になるのってちょっとビックリませんか。

print(datetime.datetime.strptime("2022-05-20 15:00:00", "%Y-%m-%d %H:%M:%S").timestamp())
# 1653026400.0

print(pd.to_datetime("2022-05-20 15:00:00").timestamp())
# 1653058800.0

この問題を解消し、日本時間として解釈してUNIX時間に変換するには、結果から 9時間分 = 32400 秒 引いてあげても良いです。ただ、コードの可読性的にいまいちなので、タイムゾーンを設定してあげるのが良いと思います。それには、 tz_localize というのを使います。
参考: pandas.Series.tz_localize — pandas 1.4.2 documentation
(tz_convert という似てるけど用途が違うものもあるので注意してください。)

df["time"] = pd.to_datetime(df.time_str)
print(df.time)
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
Name: time, dtype: datetime64[ns]
"""

# タイムゾーンを Asia/Tokyo にローカライズする
df.time = df.time.dt.tz_localize('Asia/Tokyo')
print(df.time)
"""
0   2022-05-20 15:00:00+09:00
1   2022-05-20 15:00:00+09:00
2   2022-05-20 15:00:00+09:00
Name: time, dtype: datetime64[ns, Asia/Tokyo]
"""

# タイムスタンプに変換
print(df.time.apply(lambda t: int(t.timestamp())))
"""
0    1653026400
1    1653026400
2    1653026400
Name: time, dtype: int64
"""

今度は正しく、 1653026400 になりました。
これで、 to_datetimeを使って生成された時刻データもUNIX時間に変換できましたね。

ちなみに、逆にPandasのデータでUNIX時間の列があった場合にそれを時刻に変換したい場合は、もうdatetimeライブラリに頼った方がいいと思います。

sr = pd.Series([1653026400, 1653026400, 1653026400])

print(sr.apply(datetime.datetime.fromtimestamp))
"""
0   2022-05-20 15:00:00
1   2022-05-20 15:00:00
2   2022-05-20 15:00:00
dtype: datetime64[ns]
"""

以上で、標準ライブラリを使う場合とPandasを使う場合で、時刻とUNIX時刻の相互変換ができるようになりました。

Amazon Aurora Serverless v2が出たので使ってみた

以前の記事で、 Aurora Serverless を紹介しましたが、その v2 が登場していたので使用感を確かめてみました。
参考: Amazon Aurora Serverlessを使ってみる
Amazonからの発表: Amazon Aurora Serverless v2 の一般提供を開始
Amazon Aurora Serverless

かつてのAurora Serverless (要するにv1)には、ServerlessではないAuroraに比べてさまざまな制約がありましたが、v2ではそれらの制約がだいぶ改善されています。
個人的に期待しているというより嬉しいのは、グローバルデータベースが作れる点ですね。
これまでのAurora ServerlessはAWS内部からしかアクセスできなかったのでローカルからアクセスする場合はEC2などで踏み台作ることが必須でした。

注意点として、ACUあたりの料金が2倍になっています。ただし、スケーリング等がよりきめ細やかに行われるようになっているらしいので、同じ使い方した場合の請求金額がそのまま2倍になるようなことはないとも聞いています。これは実際に使ってみないとわからないですね。

クラスター接続編

さて、前置きが長くなって来たので使ってみましょう。手順は以下の通りです。

1. AWSのコンソールにログインし、RDSの管理画面に移動する。
2. [データベースの作成]をクリック
3. 標準作成を選択 (簡単作成でも良い)
4. エンジンのタイプ は [Amazon Aurora]を選択
5. エディション は [MySQLとの互換性を持つ Amazon Aurora]
6. MySQLのバージョンを選ぶ。
以前は、大量の選択肢の中からドキュメントで指定されているバージョンを自分で選んでおかないと以降の選択肢からサーバレスが消えていたのですが、今は「フィルターの表示」で、「Serverless v2 をサポートするバージョンを表示」として絞り込むことができます。(v1も同じフィルターが欲しいが無いようです。)
現時点では、「Aurora MySQL 3.02.0 (compatible with MySQL 8.0.23)」だけに絞られるのでこれを選びます。

7. テンプレートは個人利用の検証なので「開発/テスト」にしました。
8. DBクラスター識別子 は何か名前をつける。 (yutaro-aurora-serverless-v2-test)
9. マスターユーザー名 を指定 (デフォルトは admin)
10. パスワードの設定。 (自分はパスワードの自動作成にしました)
11. インスタンスの設定で、DBインスタンスクラスでサーバレスを選択。
12. キャパシティーの設定を変更。(0.5〜128で設定可能ですが、予算を抑えるため低めに。)
13. 接続のパブリックアクセスで「あり」を選択。
14. [データベースの作成]をクリック。

これで、DBの作成が始まります。
[認証情報の詳細を表示] から、 adminのパスワードを入手しておきましょう。

アクセスにはエンドポイントの名前も必要です。DBクラスター識別子とは違うので別途確認します。(DBの一覧から先ほど作ったクラスターを選択すると表示されます。)

あとは、セキュリティグループを設定して自分のPCからアクセスできるようにしたら準備完了です。

DBアクセス編

ローカル端末からPythonでアクセスしてみます。まだ空っぽのDBなので、とりあえずSHOW DATABASES文でも打ってみましょう。

import pymysql.cursors

con_args = {
    "host": "{エンドポイント名}",
    "port": 3306,
    "user": "{DBユーザー名}",
    "password": "{DBパスワード}",
    "charset": "utf8mb4",
    "cursorclass": pymysql.cursors.DictCursor,
}
# 通常は "database": "{データベース名}" も指定するが、まだ何も作ってないのでなし。

connection = pymysql.connect(**con_args)

with connection.cursor() as cursor:
    sql = "SHOW DATABASES"
    cursor.execute(sql)
    result = cursor.fetchall()
print(result)

# [{'Database': 'information_schema'}, {'Database': 'mysql'}, {'Database': 'performance_schema'}, {'Database': 'sys'}]

無事接続できたようです。

日本語対応編

ちなみに、 v1の頃は、日本語を使うために文字コードなどの設定が必要でした。
参考: Amazon RDS(Aurora)で日本語文字列や絵文字を使えるようにする

それがどうやら、v2では(というより、選択した互換MySQLのバージョンがそうだった可能性もありますが) 最初からutf8対応の設定のようです。

with connection.cursor() as cursor:
    sql = "SHOW VARIABLES LIKE 'char%'"
    cursor.execute(sql)
    result = cursor.fetchall()


for r in result:
    print(r)
"""
{'Variable_name': 'character_set_client', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_connection', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_database', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_filesystem', 'Value': 'binary'}
{'Variable_name': 'character_set_results', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_server', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_system', 'Value': 'utf8'}
{'Variable_name': 'character_sets_dir', 'Value': '/rdsdbbin/oscar-8.0.mysql_aurora.3.02.0.0.12759.0/share/charsets/'}
"""

未使用時の停止について

最後に、一番気になる未使用時の自動停止について。個人利用においては正直 Aurora Serverless を使う1番の理由は使わない時は止まって料金がかからない点です。

v1 の時は、 「数分間アイドル状態のままの場合コンピューティング性能を一時停止する」という設定があり、これを設定しておくと止まってくれていたのですが、 v2 ではこれに該当する設定を見つけられていません。(キャパシティーの設定にあるはずなのですが。)

ドキュメントにある、「Aurora Serverless v2 を使用すると、SaaS ベンダーは、プロビジョニングされた容量のコストを気にすることなく、個々の顧客ごとに Aurora データベースクラスターをプロビジョニングできます。データベースが使用されていないときは自動的にシャットダウンしてコストを節約し、変化するアプリケーション要件に合わせてデータベースの容量を即座に調整します。」の部分はどこで使うのでしょう。
v1 の時、デフォルトだと思って設定し忘れていたらDBが停止しておらず、普通に料金がかかったのでここが心配です。

もし、 v2 で自動的なシャットダウンが実装されていなかったら個人開発では引き続きv1を使わないといけないですね。一旦、放置して様子を見ようと思います。

(追記)
こちらに情報がありました。
Comparison of Aurora Serverless v2 and Aurora Serverless v1

Stopping a cluster のところを見ると、v2は手動で停止しないといけなくて(You can manually stop and start the cluster by using the same cluster stop and start feature as provisioned clusters.)、v1は自動的に停止できる(The cluster pauses automatically after a timeout.)とありますね。これだとちょっと使えないので残念です。

タイムゾーンについて

もう一点1気になる問題があって、タイムゾーンの設定変更が反映されないんですよね。反映されるまでに時間がかかるのかな。DB クラスターのパラメータグループの time_zone を Asia/Tokyo に変えたのですが、 SELECT NOW() すると UTCの時刻が返って来ます。これも要調査です。

(追記)こちらはクラスターに設定した後、インスタンスを再起動したら反映されました。

sentencepieceを使ってみた その2 (model_type: word)

前回の記事に続いて、sentencepieceの話題です。
今回はmodel_type=”word”を使ってみます。

最初に、自分が勘違いしていたことについて説明します。このmodel_type=”word”ですが、自分はてっきりいわゆるsubwordのアルゴリズムが実装されているものだと勘違いしていました。あらかじめ分かち書きしたテキストを読み込み、低頻度語を洗い出して、より小さい単位、最終的には文字単位に分解して未知語を無くしてくれるのかなと。
しかし、実際の挙動は、分かち書きしたテキストデータからそのまま語彙を学習し、idへ変換する機能のようです。下記のサンプルファイルでも、charとwordは同じカテゴリで扱われていますね。
参考: sentencepiece/sentencepiece_python_module_example.ipynb at master · google/sentencepiece · GitHub

これだとあまりありがたみがなく、わざわざ記事にするほどでもなかったのですが、前回の記事で次はこれ紹介するって書いちゃったのでやってみます。

ドキュメントに、「The input sentence must be pretokenized when using word type.」と書かれている通り、model_typeでwordを指定する場合は、入力データをあらかじめ単語に区切っておく必要があります。

早速、入力データを作ってやってみましょう。前回同様ライブドアニュースコーパスのデータを使い、今回はMeCabで分かち書きしてテキストファイルに書き出しておきます。
(ちなみに、試しに分かち書きして無い状態でsentencepieceに食わせてみるのも実験しましたが、確かにすごく長い単語ばかりの変な学習結果になりました。)
pretokenized の詳細な仕様が明記されて無いですが、常識的に考えて半角スペースあたりで区切れば大丈夫です。(実際それで動きました。)

import pandas as pd
import MeCab
import subprocess


# ライブドアニュースコーパス読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")

# 辞書のディレクトリ取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
# 分かち書きを出力する設定でTaggerを生成
tagger = MeCab.Tagger(f"-O wakati -d {dicdir}/ipadic")

# 分かち書き
df["tokens"] = df["text"].apply(tagger.parse)
# 末尾に改行コードがついているので取り除く
df["tokens"] = df["tokens"].str.strip()

# ファイル書き出し
with open('livedoor_tokenized_corpus.txt', 'w') as w:
    for text_line in df["tokens"]:
        w.write(text_line + "\n")

データができたので、前回の別のモデルと同様に、語彙を学習させます。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_tokenized_corpus.txt",  # コーパスファイル
    model_type="word",  # デフォルト
    model_prefix='livedoor_word',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

さて、保存されたモデルファイルを読み込んで使ってみましょう。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_word.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"
# model_type="word"の場合は、入力データも分かち書きして渡す必要がある
sample_tokens = tagger.parse(sample_text).strip()
print(sample_tokens)
# これから の 年度 末 に 向け て 、 引越し を 考え て いる 人 も 多い の で は ない だろ う か ?


# 単語のid列への分割
print(sp.encode(sample_tokens))
# [823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]
# 文字列への分割
print(sp.encode_as_pieces(sample_tokens))
# ['▁これから', '▁の', '▁年度', '▁末', '▁に', '▁向け', '▁て', '▁、', '▁引越し', '▁を', '▁考え',
# '▁て', '▁いる', '▁人', '▁も', '▁多い', '▁の', '▁で', '▁は', '▁ない', '▁だろ', '▁う', '▁か', '▁?']

特殊文字 “▁”が各単語の前についていますが、encode_as_piecesによる区切り自体は、元のMeCabの区切り位置から何も変わっていないのがわかりますね。これは基本的には、分かち書きされたトークン列をid列に変換してくれているだけだとわかります。また、よくみるとid列の中に0 (対応する単語は”引越し”)が出ているのがわかります。これが語彙数の上限4000から溢れた未知語です。id列を文字列に復元するとよくわかります。

# id列を文章に戻す
print(sp.decode_ids([823, 4, 1977, 1233, 7, 209, 10, 3, 0, 6, 353, 10, 23, 47, 18, 223, 4, 12, 9, 24, 95, 53, 33, 52]))
# これから の 年度 末 に 向け て 、 ⁇  を 考え て いる 人 も 多い の で は ない だろ う か ?

「引越し」を引/越/しに分割して未知語をなくしてくれてるいわゆるsubword処理をやってくれると嬉しかったのですが、冒頭に書いた通りそれは単なる僕の勘違いでした。
低頻度語は未知語としてそのまま捨てられちゃいます。

前回と今回の記事をまとめると、sentencepieceは原則、model_typeは unigramかbpeで使うもののようですね。この二つのアルゴリズムはなかなか便利ですよ。

sentencepieceを使ってみた(model_type: unigram / bpe / char)

このブログではテキストを単語に分割するときは概ねMeCabを使っていますが、実はMeCab以外にもテキストを分割する方法はいろいろあります。その中の一つであるsentencepieceを試してみたので紹介します。ちなみにこのsentencepiece、開発者はMeCabと同じ工藤拓さんです。
参考: SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing

sentencepieceとMeCabの違いとしては、MeCabは文章を文法的な意味を持つ最小単位の単語(形態素)に分割する形態素解析エンジンなのに対して、sentencepieceは特に文法的な意味を考慮せずに分割するということがあります。
MeCabは辞書をもとに文章を分割しますが、sentencepiece(unigram/ bpe)はそうではなく生の文章から自動的に分割する単位を学習し、語彙を習得します。
unigram とか bpe というのはそのときのアルゴリズムにつけられた名前です。
この他、model_type=”word”ってのがありますが、これは特殊で、あらかじめ単語に分割されたデータから低頻出語をさらに分割することで学習します。(学習データの準備が違うので次の記事で紹介します。)
これだけだと何もメリットなさそうなのですが、sentencepieceでは語彙の数を事前にパラメーターで指定することができ、例えば語彙を8000語に収めたいなら8000語で学習するといったことができます。BoWのような表現をする場合はもちろんですが、RNNやTransformer系の機械学習モデルへの前処理として使う場合、埋め込み層の語彙数を事前に指定しないといけないので非常に便利な特徴ですね。また、コーパス全体で1,2回しか出てこないような低頻度語を防げるのもありがたいです。

GitHubのPythonラッパーのサンプルコードを見ながら動かしてみましょう。
参考: sentencepiece/python at master · google/sentencepiece · GitHub

サンプルに使うデータは例によってライブドアニュースコーパスです。ニュース記事本文を使います。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

ちょっと使い方が独特で、まずモデルを学習するためのコーパスをテキストファイルで準備する必要があります。上記の記事で作ったライブドアニュースコーパスのCSVを、1記事1行のテキストに加工して保存しておきます。

import pandas as pd

# ライブドアニュースコーパスの本文を、1記事1行のテキストファイルとして書き出し
df = pd.read_csv("./livedoor_news_corpus.csv")
# 改行除去
df["text"] = df.text.str.replace("\n", " ")
# 全角空白除去 (blog記事上だと分かりにくいですが、全角スペースを半角スペースにしてます。)
df["text"] = df.text.str.replace(" ", " ")
# Line Separator 除去
df["text"] = df.text.str.replace("\u2028", " ")
# 前後の空白除去
df["text"] = df.text.str.strip()

# ファイル書き出し
with open('livedoor_corpus.txt', 'w') as w:
    for text_line in df["text"]:
        w.write(text_line + "\n")

これでコーパスができました。
このあと、sentencepieceのモデルを学習します。これもかなり使い方が特殊で、先ほど作ったテキストファイルと各種オプションを指定して学習を実行すると、モデルのバイナリ(.model)と、語彙(.vocab)の二つのファイルが出来上がります。ファイル名の拡張子以前の部分は model_prefix 引数で指定した文字列です。では早速、デフォルトの unigramモデルでやってみましょう。語彙数は適当に4000としました。

import sentencepiece as spm


spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",  # コーパスファイル
    model_type="unigram",  # デフォルト
    model_prefix='livedoor_unigram',  # 出力されるモデルのファイル名に使われる
    vocab_size=4000,  # 語彙数
)

これで以下のように、livedoor_unigram.model/ livedoor_unigram.vocab ファイルが出来上がります。


$ ls
livedoor_corpus.txt
livedoor_unigram.model
livedoor_news_corpus.csv
livedoor_unigram.vocab

livedoor_unigram.vocabを開くと学習した語彙が見れます。

$ head -n 20 livedoor_unigram.vocab
<unk>	0
<s>	0
</s>	0
の	-3.17217
、	-3.41012
。	-3.81098
▁	-3.85549
を	-4.01854
が	-4.10413
に	-4.21314
は	-4.29892
で	-4.61122
と	-4.6203
」	-4.65722
「	-4.80934
も	-5.02828
な	-5.19927
)	-5.20129
(	-5.30642
い	-5.32545

先頭部分が1文字ばっかりで分かりにくいですが、ファイルの途中見ていくと、「する」とか「という」とかの単語もちゃんと出て来ます。<unk>,<s>,</s>の3単語がデフォルトで予約語とされていますが、この3語を含めて4000語です。control_symbolsやuser_defined_symbols という引数を使って、自分で定義したシンボルを入れることもできます。
参考: sentencepiece/special_symbols.md at master · google/sentencepiece · GitHub

さて、モデルが学習できたのでこれ使ってみましょう。出来上がったモデルファイルを読み込んで、それを使って文章をトークン化します。

# モデルの読み込み
sp = spm.SentencePieceProcessor(model_file='./livedoor_unigram.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp.encode(sample_text))
[6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]
# 文字列への分割
print(sp.encode_as_pieces(sample_text))
# ['▁', 'これから', 'の', '年', '度', '末', 'に', '向け', 'て', '、', '引', '越', 'し', 'を', '考え', 'ている', '人', 'も多い', 'の', 'ではない', 'だろうか', '?']

# id列を文章に戻す
print(sp.decode_ids([6, 1974, 3, 44, 230, 961, 9, 529, 53, 4, 893, 1612, 22, 7, 614, 134, 69, 2203, 3, 663, 1029, 88]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

これで、テキストをトークン化とその逆変換ができましたね。

モデルを学習したときの、model_type=”unigram” の部分を model_type=”bpe” とすることでもう一つのByte pair encodingアルゴリズムも試すことができます。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="bpe",
    model_prefix='livedoor_bpe',
    vocab_size=4000,
)

unigramとbpe、そんなに大きな違いないんじゃ無いかなという予想に反して、出来上がった語彙ファイルを見ると全然違います。

$ head -n 20 livedoor_bpe.vocab
<unk>	0
<s>	0
</s>	0
てい	-0
した	-1
った	-2
する	-3
▁・	-4
して	-5
ない	-6
ている	-7
から	-8
こと	-9
って	-10
os	-11
とい	-12
ます	-13
され	-14
です	-15
ック	-16

最初っから2文字ペアの単語がたくさん出て来ますね。
学習したモデルを読み込んで使う方法は同じです。語彙が違うので微妙に結果が変わります。
たった一文の比較で優劣つけるわけにはいきませんが、この例文だとなかなかどちらがいいとも言い難いです。

# モデルの読み込み
sp_bpe = spm.SentencePieceProcessor(model_file='./livedoor_bpe.model')

# サンプルの文章
sample_text = "これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?"

# 単語のid列への分割
print(sp_bpe.encode(sample_text))
[1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]
# 文字列への分割
print(sp_bpe.encode_as_pieces(sample_text))
# ['▁これ', 'からの', '年', '度', '末', 'に向', 'けて', '、', '引', '越', 'し', 'を', '考え', 'ている', '人も', '多い', 'のではない', 'だろうか', '?']

# id列を文章に戻す
print(sp_bpe.decode_ids([1173, 596, 1561, 1747, 1915, 1334, 292, 1465, 1910, 2395, 1472, 1477, 808, 10, 710, 293, 1279, 579, 1609]))
# これからの年度末に向けて、引越しを考えている人も多いのではないだろうか?

「実践・自然言語処理シリーズ2 形態素解析の理論と実装 (近代科学社/ 工藤拓(著))」に少しだけsentencepieceについての記述もあり、unigramとbpeの違いが少し記載されています。
そのまま引用します。

BPEは、ニューラル翻訳に標準的に用いられている手法であり、1文字1語彙から開始し、連結した際に最も頻度が高くなる二つの語彙を選び新たな語彙とする手続きを決められた語彙サイズに達するまで繰り返すことで語彙結合ルールを学習します。
(中略)
ユニグラム言語モデルは、テキストを符号化するときの符号長が最小となるように、分割モデルをEM法を用いて学習します。

なるほど、って感じですね。個人的にはBPEの方が具体的なアルゴリズムがわかりやすいです。

ちなみに、ユニグラム言語モデルは符号化したときの符号長を最小にするように分割するって書いてあるので、ユニグラムモデルの方が少ない単語数になるのかと思って、学習したテキストで試したのですが結果は逆でした。不思議です。

print(df.text.apply(lambda x: len(sp.encode(x))).mean())
# 814.231301751052

print(df.text.apply(lambda x: len(sp_bpe.encode(x))).mean())
# 811.67992398534

今回の記事の主題はほぼここまでなのですが、model_typeには次の記事で取り上げるwordの他にもcharってのがあるので、一応これも紹介しておきます。

これは非常に単純なやつでして、単純にテキストを文字単位に分割します。学習するのはコーパスに登場した文字の一覧だけです。

spm.SentencePieceTrainer.train(
    input="livedoor_corpus.txt",
    model_type="char",
    model_prefix='livedoor_char',
    vocab_size=4000,
)

学習結果の語彙も予約語以外は文字単体だけです。4000種類もなかったので行数も少なくなりました。

$ head -n 20 livedoor_char.vocab
<unk>	0
<s>	0
</s>	0
の	-3.64481
▁	-3.64584
、	-3.80562
い	-3.93749
ー	-4.02605
に	-4.07836
る	-4.1384
と	-4.15518
で	-4.20032
し	-4.23772
な	-4.24788
。	-4.24846
た	-4.2548
て	-4.2787
を	-4.28831
が	-4.29299
は	-4.38982

モデルを読み込んで使う方法はunigramやbpeと同じなので略します。

WordCloudをHTML出力し、各単語にリンクを設定する

このブログで何度かワードクラウドを作って来ましたが、ちょっとした興味からそれを画像ではなくHTML出力したいと思いやってみました。
そして、単にHTML出力をするだけでなく、ワードクラウド中の単語をクリックしたら単語ごとに指定したURLにジャンプできるようにします。

この記事のサンプルではその単語でのGoogle検索結果へアクセスできるようにします。ワードクラウド自体はいつものライブドアニュースコーパスのデータで作ります。
参考: livedoorニュースコーパスのファイルをデータフレームにまとめる

実はいつも使っているワードクラウドのライブラリ(wordcloud)には、to_html()というメソッドが形だけあって、中身が実装されていないという状態でした。僕はこれが実装されるのを待っていたのですが、残念ながら最近の更新でこのメソッド自体が消されてしまいました。
参考: remove empty to_html in favor of to_svg (#607) · amueller/word_cloud@be9bb5e · GitHub

HTML出力機能はもう実装されないんだと思い、wordcloudのオブジェクトが持っているlayout_プロパティ(単語や配置場所、色などがまとまった配列)を読み解いて、自分でHTMLを作るコードを書くしかないと思ってコーディングに着手していました。
しかし改めてライブラリのコードをよく見るとto_svg()なるメソッドを持っているではないですか。これを使えばSVGで書き出せるので、簡単にHTMLに埋め込めます。
参考: wordcloud.WordCloud — wordcloud 1.8.1 documentation

SVGで書き出せたらあとはリンクを貼るだけなので簡単です。textタグたちをaタグで囲むだけです。ただ、今回はJavaScriptの addEventListener で、click したら指定のURLを開く関数をセットしました。

前置きが長くなって来たので、ここからやってみます。まずは普通にワードクラウドを作ります。

import re
import subprocess
import unicodedata
import pandas as pd
import MeCab
from wordcloud import WordCloud


# データの読み込み
df = pd.read_csv("./livedoor_news_corpus.csv")
# ユニコード正規化とアルファベットの小文字統一
df.text = df.text.str.normalize("NFKC").str.lower()
# 改行コードを取り除く
df.text = df.text.str.replace("\n", " ")
# エラーになる文字があるので取り除く (ライブドアニュースコーパス使う場合だけの処理。普通は不要)
df.text = df.text.str.replace("\u2028", "")

# 辞書のパス取得
dicdir = subprocess.run(["mecab-config", "--dicdir"], capture_output=True, text=True).stdout.strip()
tagger = MeCab.Tagger(f"-d {dicdir}/ipadic")

# ひらがなのみの文字列にマッチする正規表現
kana_re = re.compile("^[ぁ-ゖ]+$")

# 分かち書きして、ひらがな以外を含む 名詞/動詞/形容詞 を返す関数
def mecab_tokenizer(text):
    # 分かち書き
    parsed_lines = tagger.parse(text).splitlines()[:-1]
    surfaces = [l.split('\t')[0] for l in parsed_lines]
    features = [l.split('\t')[1] for l in parsed_lines]
    # 原型を取得
    bases = [f.split(',')[6] for f in features]
    # 品詞を取得
    pos = [f.split(',')[0] for f in features]
    # 各単語を原型に変換する
    token_list = [b if b != '*' else s for s, b in zip(surfaces, bases)]
    # 名詞/動詞/形容詞に絞り込み
    target_pos = ["名詞", "動詞", "形容詞"]
    token_list = [t for t, p in zip(token_list, pos) if (p in target_pos)]
    # ひらがなのみの単語を除く
    token_list = [t for t in token_list if not kana_re.match(t)]
    # アルファベットを小文字に統一
    token_list = [t.lower() for t in token_list]
    # 半角スペースを挟んで結合する。
    result = " ".join(token_list)
    # 念のためもう一度ユニコード正規化
    result = unicodedata.normalize("NFKC", result)
    return result


# 形態素解析
df["tokens"] = df.text.apply(mecab_tokenizer)
# wordcloud入力データ用に連結する
text_data = " ".join(df["tokens"])

# ワードクラウドのオブジェクト生成
wc = WordCloud(
        font_path="/Library/Fonts/ipaexg.ttf",  # 日本語フォントファイル
        width=600,  # 幅
        height=400,  # 高さ
        prefer_horizontal=0.9,  # 横書きで配置することを試す確率 (デフォルト0.9)
        background_color='white',  # 背景色
        include_numbers=False,  # 数値だけの単語を含まない
        colormap='tab20',  # 文字色のカラーマップ指定
        regexp=r"\w{2,}",  # 2文字以上の単語のみ含む
        relative_scaling=1,  # 頻度のみで文字サイズを決める
        collocations=False,  # bi-gramを考慮しない
        max_font_size=60,  # 最大フォントサイズ
        random_state=42,  # 乱数の初期値
    ).generate(text_data)

# この時点で作成できたwordcloudを確認する場合は以下の関数を実行。 
wc.to_image()
# 出力省略

さて、これでワードクラウドができました。これを to_svg() で SVGに出力するとどうなるか見てみましょう。

# to_svg() で svgの文字列を生成できる
print(wc.to_svg())
"""
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">
<style>text{font-family:'IPAexGothic';font-weight:normal;font-style:normal;}</style>
<rect width="100%" height="100%" style="fill:white"></rect>
<text transform="translate(342,63)" font-size="60" style="fill:rgb(127, 127, 127)">思う</text>
<text transform="translate(391,134)" font-size="44" style="fill:rgb(127, 127, 127)">映画</text>
<text transform="translate(239,359)" font-size="41" style="fill:rgb(174, 199, 232)">自分</text>
<text transform="translate(458,55)" font-size="39" style="fill:rgb(174, 199, 232)">記事</text>

 # 中略
 
<text transform="translate(411,86)" font-size="12" style="fill:rgb(158, 218, 229)">一番</text>
<text transform="translate(408,50)" font-size="12" style="fill:rgb(23, 190, 207)">転職</text>
<text transform="translate(58,277)" font-size="12" style="fill:rgb(255, 152, 150)">得る</text>
</svg>
"""

引数にファイル名の指定などはできず、SVGタグで囲まれた文字列を返してくれるようです。
これを .svg の拡張子で保存すると、ワードクラウドのSVGファイルが出来上がります。
先頭に、”<!DOCTYPE HTML>”をくっつけて、.htmlファイルで保存してもよいでしょう。
文字列にリンクを設定するなどの要件が無いのであれば、これをファイルに保存して完成です。

ここからリンクを設定していきます。リンク先は今回はお試しなので、Google検索結果にします。URLは、 https://www.google.com/search?q={検索キーワード} です。

前の方でも書きましたが、リンクっていう観点だとaタグで囲むのがセオリーですし、テキストを加工するスクリプトを描くのもそんなに難しくありません。ただ、文字列の加工の総量を少なめにしたいのと、イベントリスナーを使う方法の方がページ遷移させる以外の用途への応用も多いと思うので、こちらを採用しました。

Google検索結果のURLの検索キーワードはパーセントエンコーディングする必要があるので、JavaScriptのencodeURI関数を使います。僕はあまりJavaScript得意ではないので自信がないですが、次のようなコードでいかがでしょうか。
(無理矢理PythonでJavaScriptを書き出してますが、一旦SVGを書き出してテキストエディタでスクリプトを書き込んでも良いと思います。)

# textタグたちに、クリック時にGoogle検索結果を開くイベントリスナーを追加するJavaScript
link_script = """
<script>
    svg = document.getElementsByTagName("svg")[0];
    text_tags =  svg.getElementsByTagName("text")
    for(var i=0; i<text_tags.length; i++){
        text_tags[i].addEventListener(
            "click",
            function(){
                word = this.textContent;
                word_uri = encodeURI(word);
                url = "https://www.google.com/search?q=" + word_uri;
                window.open(url, "_bkank");
            }
        )
    }
</script>"""

# HTMLファイルに書き出し
with open("word_cloud.html", "w") as f:
    f.write("<!DOCTYPE HTML>\n")
    f.write(wc.to_svg())
    f.write(link_script)

出力されるhtmlファイルが次です。

ブログに公開できるデータということで、無難なデータと無難なリンク先で作っていますが、これはデータと使い方によっては結構面白いものを作れる可能性を感じませんか?