にせねこメモ

はてなダイアリーがUTF-8じゃないので移ってきました。

このブログについて

文字・フォント・プログラム・技術・趣味などについて、Twitterでは書きづらい長い内容などをまとめるためのブログです。基本的には自分用のメモとして書いている部分が多いです。

リンク等

note https://note.com/nixeneko 残らなくてもいい記事
Pixiv https://pixiv.me/nixeneko
Tumblr https://nixeneko.tumblr.com/ 絵。Pixivと同じにしたいがサボリ気味
Pleroma @nixeneko@nixeneko.info Mastodonとかやってる人はフォローしてください
Twitter @nixeneko
GitHub https://github.com/nixeneko プログラムとか
Bookmeter https://bookmeter.com/users/9166 読書記録。たまに感想
ナナシスID ZhRYMnA
D4DJ oDgpcYR9
Amazon欲しい物リスト amazon.jp/registry/wishlist/1C43ZFBA4IL6Z

同人誌(無料公開)

http://nixeneko.hatenablog.com/entry/c88_russian_alphabethttp://nixeneko.hatenablog.com/entry/c90_greek_latin_cyrillichttp://nixeneko.hatenablog.com/entry/20170811_dentyu

Pleromaのサーバ移行ログ(2021-04-25)

やったこと

Pleromaのサーバを、別のサーバに移行した。
nixeneko.info
nixeneko.info

モチベーション

2018年にPleroma (nixeneko.info)のサーバを立てた。詳しくは次の記事を参照:

…のだが、OSがUbuntu 16.04だったので、OSのサポートが今年(2021年)4月で切れてしまう。そのため、サーバ移行しないとまずいと思って移行をした。

流れ

  • 新サーバセットアップ
  • 旧→新サーバにデータベースバックアップ(一応…)
  • 新サーバにPleromaをインストール
  • 旧サーバのPleromaを新サーバのと同じバージョンに更新
  • 旧サーバ止める
  • 旧→新サーバにデータベースをバックアップ、データ移行
  • 新サーバでデータベースのリストア
  • 新サーバでPleromaのセットアップを続行
  • ドメインIPアドレス変更
  • Let'senctyptの証明書の設定
  • 起動~

作業ログ

新サーバセットアップ

新しいVPSを契約した。

  • Conoha
  • メモリ 512 MB, ストレージ 30 GB
  • Ubuntu 20.04
SSHで接続

以下NEW_SERVER_IP_ADDRESSはサーバのIPで読み替えてください。

ローカルから

ssh root@NEW_SERVER_IP_ADDRESS
ユーザー作成
apt update
apt upgrade
adduser workuser
gpasswd -a workuser sudo
su workuser
SSH設定
cd ~
mkdir .ssh
chmod 700 .ssh
cd .ssh
touch authorized_keys
chmod 600 authorized_keys
nano authorized_keys

ローカルの公開鍵をコピペ、保存

sshdの設定
sudo nano /etc/ssh/sshd_config

設定を適当に変更して次のようにした

port 10022 #コメントアウト、変更

LoginGraceTime 30 #コメントアウト、変更
PermitRootLogin no #変更
StrictModes yes #コメントアウト、変更
MaxAuthTries 3 #コメントアウト、変更
MaxSessions 4 #コメントアウト、変更

PubkeyAuthentication yes #コメントアウト

IgnoreRhosts yes #コメントアウト

PasswordAuthentication no #そのまま
PermitEmptyPasswords no #コメントアウト 

保存し、再起動

sudo sshd -t
sudo service sshd restart
rootのauthorized_keysを消す
sudo su root
rm ~/.ssh/authorized_keys

まあそもそもVPSを作成する際にrootに公開鍵登録する必要なかったのではという気もする。
ログアウト。

サーバーに接続できることを確認

ローカルから

ssh -p 10022 workuser@NEW_SERVER_IP_ADDRESS

として、新しい設定でログインし直す。

ファイアウォール(ufw)を設定
sudo ufw allow 80
sudo ufw allow 443
sudo ufw limit 10022/tcp
sudo ufw enable
sudo ufw status

参考: ufwコマンドの使い方 - Qiita

Pleromaのセットアップ

新サーバにPleromaをセットアップする。
参考: Installing on Debian Based Distributions - Pleroma Documentation

いろいろインストール
 sudo apt install postgresql postgresql-contrib elixir erlang-dev erlang-nox libmagic-dev git build-essential cmake nginx certbot imagemagick ffmpeg exiftool

Ubuntu 20.04ではElixirのバージョンが足りてるっぽいのでそのまま入れた。

Pleroma用ユーザの追加
sudo useradd -r -s /bin/false -m -d /var/lib/pleroma -U pleroma
  • -r システムアカウントを作成
  • -s /bin/false ログインシェルを指定
  • -m ホームディレクトリを作成
  • -d /var/lib/pleroma ユーザのホームディレクトリの場所
  • -U ユーザと同名のグループを作成

参考: 【 useradd 】コマンド――新規ユーザーを作成する:Linux基本コマンドTips(255) - @IT
ユーザpleromaでのコマンドの実行はsudo -Hu pleroma commandとする

Pleromaのインストール
sudo mkdir -p /opt/pleroma
sudo chown -R pleroma:pleroma /opt/pleroma
sudo -Hu pleroma git clone -b stable https://git.pleroma.social/pleroma/pleroma /opt/pleroma
cd /opt/pleroma
sudo -Hu pleroma mix deps.get

hexのインストールをするかどうかにはyを入力

sudo -Hu pleroma mix pleroma.instance gen

rebar3のインストールをするか→yを入力
この後いろいろな質問がなされる。まあ適当に書く。

データベース名、データベース用のユーザー名、パスワード、を旧サーバーに合わせる。

  • データベース名がpleroma-dev
  • データベースに繋ぐユーザーがpleroma
  • データベースに繋ぐパスワードは旧サーバのprod.secret.exsに合わせる
  • アップロードファイル名の匿名化はyにした
sudo -Hu pleroma mv config/generated_config.exs config/prod.secret.exs
sudo -Hu pleroma nano config/prod.secret.exs

前の設定ファイルを参考に編集する。

  secret_key_base: "前の設定ファイルと合わせる?",
  #…
  limit: 400,
  registrations_open: false

config :pleroma, Pleroma.Repo,
  #…  
  password: "前のやつと合わせる",
  database: "pleroma_dev",

旧サーバから新サーバにバックアップ

念のため。

sshfsを使ってリモートサーバに書き出す方法、いらなかったけど残しておく。

そんな空き容量ないと思っていたので、ネットワーク越しにバックアップデータを出力させたけど、データをcustomフォーマットでダンプしたら300 MB程度だったので、正直sshfsでリモートに書き出さなくても手元で書き出せばよかった。

旧サーバにログイン

sshfsのインストール
sudo apt install sshfs
sshfsでマウント

初期設定

screen
mkdir remote
sudo nano /etc/fuse.conf

user_allow_otherコメントアウト、保存
参考: sshfsで別サーバのディレクトリをマウントする | server-memo.net
参考: ssh - sshfs mount, sudo gets permission denied - Unix & Linux Stack Exchange
マウント

sshfs workuser@NEW_SERVER_IP_ADDRESS:/home/workuser/share /home/workuser/remote -p 10022 -o allow_other

バックアップ

cd /home/pleroma/pleroma/
sudo -Hu postgres pg_dump -d pleroma_dev --format=custom -f /home/workuser/remote/pleroma.pgdump
旧サーバのPleromaをアップデート

developブランチだった。新サーバにインストールしたバージョンに合わせてv2.3.0にアップデート

sudo su pleroma
git pull
git checkout -b backup v2.3.0
MIX_ENV=prod mix deps.get
MIX_ENV=prod mix ecto.migrate
exit
sudo systemctl restart pleroma
データベースをダンプ

参考: Backup/Restore/Move/Remove your instance - Pleroma Documentation

sudo systemctl stop pleroma
cd /home/pleroma/pleroma
mkdir /home/workuser/bkup
chmod 777 /home/workuser/bkup
sudo -Hu postgres pg_dump -d pleroma_dev -v --format=plain -f /home/workuser/bkup/pleroma_dev.sql
scp -P 10022 ~/bkup/pleroma_dev.sql workuser@NEW_SERVER_IP_ADDRESS:~/share/pleroma_dev.sql

後で書くが、手順書に従って--format=customとしたが、うまくリストアできなかったので(今考えるとオプションの-1外したらうまく行ったかもしれないが…)、--format=plainにした。そしたら容量が3 GB程度になった。容量に余裕なければsshfs使うと良いかも。他にも方法はあるっぽいが。

ファイルのバックアップ

新サーバで

sudo -Hu pleroma chmod 777 /opt/pleroma/uploads
sudo -Hu pleroma chmod 777 /opt/pleroma/config

旧サーバで

scp -P 10022 -r uploads uploads/ workuser@NEW_SERVER_IP_ADDRESS:/opt/pleroma
scp -P 10022 config/setup_db.psql workuser@NEW_SERVER_IP_ADDRESS:/opt/pleroma/config/setup_db.psql.old

新サーバで

sudo chown -R pleroma:pleroma /opt/pleroma/uploads
sudo chown pleroma:pleroma /opt/pleroma/config/setup_db.psql.old
sudo -Hu pleroma chmod 775 /opt/pleroma/uploads
sudo -Hu pleroma chmod 775 /opt/pleroma/config
sudo -Hu nano /opt/pleroma/config/setup_db.psql

setup_db.psql.oldを参考に編集(必要があれば)。ユーザー、パスワード等をあわせる

データベースの復旧

新サーバで

cd /opt/pleroma
#sudo -Hu postgres psql -c 'DROP DATABASE pleroma_dev;'; sudo -Hu postgres psql -c 'DROP USER pleroma;'
sudo -Hu postgres psql -f config/setup_db.psql
sudo -Hu postgres psql -a -f /home/workuser/share/pleroma_dev.sql -d pleroma_dev

(2行目の#で始まる行は既にデータベースを作っちゃっていた場合に実行する)

最初、手順書どおりに--fomrat==customで出力してpg_restoreでやってたらうまくリストアできなくて、次のようなエラーが出た。

pg_restore: error: could not execute query: ERROR:  schema "public" already exists
Command was: CREATE SCHEMA public;

って出た。ので代りに--format=plainで出力してpsqlでリストアしたらうまくいったっぽい。PostgreSQLのバージョンが9.6→12.6とかなり離れてたのが原因かもしれない。

データベースのマイグレーション

まあ一応…

sudo -Hu pleroma MIX_ENV=prod mix ecto.migrate
動くか確認
sudo -Hu pleroma MIX_ENV=prod mix phx.server

動くのを確かめたら、Ctrl-C→aで終了

ドメインDNSレコードを更新

ドメインDNSレコード設定でIPアドレスを旧サーバ→新サーバに変更

Nginxの設定

新サーバにて行う

SSL証明書を設定
sudo mkdir -p /var/lib/letsencrypt/
sudo systemctl stop nginx.service
sudo certbot certonly --email EMAIL_ADDRESS -d SERVER_DOMAIN --standalone

EMAIL_ADDRESSSERVER_DOMAINは環境に合わせる。
/etc/letsencryptに保存される。

…と、これ、やったけど、証明書はドメインに対して発行されるので、IP変わっても旧サーバからコピーしてくればそれで済むっぽい。

NginxへのPleromaの設定
sudo cp /opt/pleroma/installation/pleroma.nginx /etc/nginx/sites-available/pleroma.nginx
sudo ln -s /etc/nginx/sites-available/pleroma.nginx /etc/nginx/sites-enabled/pleroma.nginx

設定の編集

sudo nano /etc/nginx/sites-available/pleroma.nginx

次のように変更。SERVER_DOMAINドメイン名に変える。

    server_name SERVER_DOMAIN;
    #…
    server_name SERVER_DOMAIN;
    #…
    ssl_trusted_certificate   /etc/letsencrypt/live/SERVER_DOMAIN/chain.pem;
    ssl_certificate           /etc/letsencrypt/live/SERVER_DOMAIN/fullchain.pem;
    ssl_certificate_key       /etc/letsencrypt/live/SERVER_DOMAIN/privkey.pem;
Nginx起動
sudo systemctl enable --now nginx.service
sudo cp /opt/pleroma/installation/pleroma.service /etc/systemd/system/pleroma.service

必要に応じて/etc/systemd/system/pleroma.serviceを編集

sudo systemctl enable --now pleroma.service

let'sencryptの自動更新、設定した覚えないけど/etc/cron.d/certbotに何か入ってる…。これでそのまま使えるのかな。更新されなかったら更新スクリプトを設定する必要があるかも。

Static Dir

参考: Static Directory - Pleroma Documentation

config :pleroma, :instance,
  #…
  static_dir: "instance/static/"

mix ecto.migrateして再起動

復元
  • instance/static/static/terms-of-service.html
  • instance/static/instance/panel.html
  • instance/static/instance/thumbnail.jpeg
  • instance/static/static/logo.png

問題点

動いた。何とか…

なんかわからんけどハッシュタグタイムラインが開けないっぽい。何が悪いのかわからんので困る。それ以外は動いているのでとりあえず様子見…。

Raspberry Pi Zero Wで温度ロガーをつくる

Raspberry Pi Zero Wと温度センサを利用して、屋外の温度を記録する温度ロガーをつくった。記録した温度データをブラウザから閲覧する仕組みもつくった。

モチベーション

外気温を記録出来たら楽しいので。

注意

素人が適当に勘と見よう見まねでつくっているだけなので、不適切な部分があるかもしれません。参考にする場合は専門家に相談するようにしてください。責任取れませんので…。

システム構成

  • Raspberry Pi Zero Wを使う。
    • 家の外に設置して記録したデータを取り出すのに、家のWifiに繋がれば楽なので。というかSSHで繋げられるので管理が楽。
  • 16GBのmicro SDカード
  • 温度センサは防水プローブに入ったDS18B20を利用
  • あとプルアップ用に4.7 kΩの抵抗器が1つ必要。プルアップ用なので別にその辺りの抵抗値なら何でも良さそう。

環境

母艦パソコンのOSはWindows 10で、Cygwinsshを使っている。

Raspberry Pi Zero Wのセットアップ

Raspberry Pi OSのインストール

  • SDカードをパソコンに挿す
  • https://www.raspberrypi.org/software/ からDownload for WindowsをクリックしてRaspberry Pi Imagerをダウンロードして実行する。
    1. CHOOSE OS→Raspberry Pi OS (32-bit)
    2. CHOOSE STORAGE→SDカードを選択
    3. WRITEをクリック
    • 書き込みが終ったらCONTINUEを押して、ウィンドウを閉じる
  • 入れてみたらバージョンは Raspbian GNU/Linux 10 (buster) だった。

ssh, Wifiのセットアップ

  • パソコンにSDを挿しなおす
  • イメージを書き込んだSDカード(boot)を開く→sshという名前の空ファイルを作成(拡張子なし!)
  • SDカード(boot)の直下にwpa_supplicant.confというファイルを次の内容で作成する
ctrl_interface=/var/run/wpa_supplicant
network={
    ssid="xxxxxxxxxxxxxxxxxxxx"
    psk=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    key_mgmt=WPA-PSK
}

ssidは繋ぎたい無線LANSSIDを入力する(クォートで囲む)。
pskには次のサイトにSSIDパスフレーズを入力して計算した16進ハッシュ値を書く(クォートで囲まない)。

(改行コードはLFにしたけどCRLFでも動くんだろうか?)

電源につないで起動する。

Raspberry Piへの接続

起動すると自動的にWifiに接続される。IPアドレスRaspberry Pi Zero Wにディスプレイを繋げていればそこに表示されるが、わからなければAdvanced IP Scannerなどを利用して調べる。ローカルIPは限られてるので総当たりでも何とかなるはず。

以下、Raspberry Piのアドレスを192.168.11.10とする。
パソコンからRaspberry PiSSHで繋げる。

ssh pi@192.168.11.10
  • Are you sure you want to continue connecting (yes/no/[fingerprint])と聞かれたら yesと入力してEnter
  • 初期パスワードraspberryを入力してログイン

初期設定

パスワード変更
passwd

現行のパスワード(raspberry)1回、変更後の好きなパスワード2回を入力してパスワードを変更する。


adminのパスワードを変更するのは、

sudo passwd admin

とする。

ソフトウェアを最新に更新
sudo apt update
sudo apt upgrade -y
タイムゾーンを東京にする
sudo raspi-config

で設定を開き、

  • 5 Localisation OptionsTimezoneAsiaTokyoFinish

参考: Raspberry Piの設定【raspi-config/言語・タイムゾーン・キーボードの設定】 - Aldebaranな人のブログ

IPアドレスの固定
sudo nano /etc/dhcpcd.conf

次のような内容を書き加えるか、コメントアウトを外して変更する。アドレスは環境に合わせる。ここではルーターのアドレスを192.168.11.1としている。

interface wlan0
static ip_address=192.168.11.10/24    
static routers=192.168.11.1
static domain_name_servers=192.168.11.1 8.8.8.8

参考: TCP/IP networking - Raspberry Pi Documentation

SSHを公開鍵認証だけにする

cd ~
mkdir .ssh
nano .ssh/authorized_keys

(ここで接続元のコンピュータの公開鍵を書き込む)

chmod 600 .ssh/authorized_keys
chmod 700 .ssh
SSHD設定
sudo nano /etc/ssh/sshd_config

コメントアウトしたりして次のような設定に変更する。書いてない部分はそのまま。

Port 22 #外部公開しないのでデフォルトで良し

LoginGraceTime 30
PermitRootLogin no
StrictModes yes
MaxAuthTries 3
MaxSessions 5

PubkeyAuthentication yes

IgnoreRhosts yes

PasswordAuthentication no
PermitEmptyPasswords no

参考: sshd_configの設定項目の理解を目指す | Unskilled?

センサーのはんだ付け

接続

f:id:nixeneko:20210501175928p:plain
f:id:nixeneko:20210501175942p:plain
(画像はFritzingで作成)

写真

f:id:nixeneko:20210501171446p:plain
f:id:nixeneko:20210501171307p:plain
面倒なので直にはんだ付けした。構成を変更したくなったら面倒だけど…。

参考: 5ドル!ラズパイ・ゼロ(Raspberry pi Zero)でIoT (16) ディジタル温度センサ1 1-Wire DS18B20 | 電子工作の環境向上

1-Wire有効化

sudo nano /boot/config.txt

/boot/config.txtを開き、

dtoverlay=w1-gpio

を追記、保存、再起動。

sudo reboot now

参考: 1-WIRE at Raspberry Pi GPIO Pinout

温度自動記録の設定

自動記録のコードを用意した。温度を読み取ってファイルに時刻と温度を書き込むだけのコード。
github.com

これを動かす。

cd ~
git clone https://github.com/nixeneko/record_temperature
cp settings.py.template settings.py
ls /sys/bus/w1/devices/
nano settings.py 

/sys/bus/w1/devices/の中に温度センサのデバイスがあるので、使いたい温度センサ(たぶん28-で始まってるやつ)をメモして、settings.pyをそれに合わせて変更する。

自動実行の設定

cron.dに書く

sudo nano /etc/cron.d/record_temperature

で設定ファイルを開いて、

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

*/1 * * * * pi /home/pi/record_temperature/rec_temp.py

と書き込み、保存する。これは1分毎に記録する場合。*/1*/5にすれば5分ごとの記録になる。(*/1*と同じ。)

防水プラボックスの加工

f:id:nixeneko:20210501183207p:plain
屋外に設置するので防水プラボックス(今回使ったのはタカチ BCAP112107T)に入れる。大は小を兼ねるということで大きめにした、入らなかったら嫌なので。固定用の金具も購入。蓋が透明な奴にしたけど別に蓋が透明である必要なかったなと思った。

配線を通すために穴を開ける。

穴あけ

f:id:nixeneko:20210501183527p:plain
ドリルで穴を開け、穴をつなげてやする。

f:id:nixeneko:20210501183655p:plain

プラボックスにいれる

f:id:nixeneko:20210501184745p:plain
延長コード通す穴がでかすぎるので木材を切ってストッパーにする。動くと困るので接着剤で接着した。
f:id:nixeneko:20210501184118p:plain
f:id:nixeneko:20210501184201p:plain

シリコンシーラントで穴をふさぐ

f:id:nixeneko:20210501184830p:plain
これ本当にうまく行ってんのか謎。
ケーブルが上下に動くと普通にはがれるっぽいのでケーブルを固定してからやった方がいいっぽい。最終的に設置してからもう一回もりもりやった。

延長コードと遮断機付きのコードを繋ぎ、自己融着テープで絶縁・防雨対策

延長コードと遮断機付きのコードは適当に屋外でも使えそうなやつを通販で買った。室外機とかの屋外配線見る限りもっと簡易な作りのやつが使われてるような気がするんだけど…。

f:id:nixeneko:20210501185311p:plain

過電流遮断器なので短絡くらいしか検出できないけど、まあないよりマシかなあという感じ。漏電は配電盤の漏電遮断器が検出してくれると思う。

コード同士を接続し、自己融着テープで絶縁する。
f:id:nixeneko:20210501185422p:plain
自己融着テープは光に弱いらしく、ビニールテープでさらに巻いておくといいらしい。直射日光に当たらないのでそのままにしたが。

接地

2x4材を立てて、そこにタッピングネジでプラボックスを固定。
f:id:nixeneko:20210501190431p:plain
コードを屋外コンセントにつなぎ、電源を入れる。

インターネットからドメインでアクセスできるようにする

Dynamic DNS申込み

https://www.mydns.jp/ を使った。別にドメインが取れればなんでもいいと思う。

サービスに合わせて設定する。

IP更新

cd ~
nano update_ip.sh

を次の内容で作成する。

#!/bin/sh
wget https://ipv4.mydns.jp/login.html --http-user=ID --http-passwd=PASSWORD -O /dev/null

IDPASSWORDは自分のアカウントに合わせて変更し、保存する。

chmod +xしてcrontabで一日一回実行するように設定

chmod +x update_ip.sh
./update_ip.sh

IPの更新が動いているのを確かめる。

自動更新の設定

sudo nano /etc/cron.d/update_dynamic_dns

次の内容にして保存する。

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

12 5 * * * pi /home/pi/update_ip.sh

(環境変数の設定、とりあえず書いといたけど必要ないかも?)

これでドメインからIPが引けるようになるが、まだルータで止まってアクセスはできない。

参考: DDNSにBasic認証でIPアドレスを通知する - Qiita

ローカルからドメインでアクセスできるようにする

Raspberry PiDNSサーバーにする

Dnsmasqのインストール

sudo apt -y install dnsmasq 
sudo nano /etc/dnsmasq.conf

設定は、Raspberry PiのIPが192.168.11.10、ルータのIPが192.168.11.1ドメインexample.comの場合を書く。次の項目のコメントアウトを外し、serverはルータ(というかDNSサーバ)のIPに書き換える。

domain-needed
bogus-priv
no-resolv
address=/example.com/192.168.11.10
server=192.168.11.1

有効化する。

sudo systemctl restart dnsmasq
sudo systemctl enable dnsmasq

参考: ラズパイ(Raspberry Pi)とDnsmasqで作るDNSサーバ | Device Plus - デバプラ

ルータのDNSルーティング設定を行う

ルータの設定画面を開き、
詳細設定→DNSルーティング設定(ルータによって異なる)から

を追加。

これでローカルからもドメインでアクセスできるようになった。

気温のグラフを表示するWebサイト+CGIの設置

サイトのデータ置く用のユーザwwwを追加(この辺知識がないので何がベストプラクティスなのか分からない…)

sudo adduser --shell /bin/false www

適当に入力してユーザを作成。


サイトのデータを用意を次に用意したのでこれを使う。特定の日のデータをJSONで取得するCGIと、JSONで取得した温度データをChart.jsを使ってグラフにするHTMLファイルからなる。
github.com

用意。

sudo -Hu www /bin/bash
cd ~
git clone https://github.com/nixeneko/temperature_website
cd temperature_website/public/
cp settings.js.template settings.js
cp api/settings/settings.py.template api/settings/settings.py

Ctrl+D押してログアウト

HTTPサーバの設定

Apach2のインストールと設定

sudo apt -y install apache2
sudo nano /etc/apache2/sites-available/example.com.conf

設定を環境に合わせて次のようにする。

<VirtualHost *:80>
    ServerAdmin webmaster@localhost
    ServerName example.com
    DocumentRoot /home/www/temperature_website/public
    <Directory /home/www/temperature_website/public>
        Require all granted
    </Directory>
    <Directory /home/www/temperature_website/public/api>
        Options +ExecCGI
        SetHandler cgi-script
    </Directory>
    <Directory /home/www/temperature_website/public/api/settings>
        Require all denied
    </Directory>
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

サイトやモジュールの有効化

sudo a2enmod cgid
sudo a2ensite example.com.conf
sudo a2dissite 000-default.conf
sudo apache2ctl configtest
sudo systemctl restart apache2
sudo gpasswd -a www-data www

参考: Ubuntu 20.04にApache Webサーバーをインストールする方法 | DigitalOcean

ファイアフォールの設定(ufw)

sudo apt install ufw
sudo ufw default deny
sudo ufw allow 53
sudo ufw allow 80/tcp
sudo ufw limit 22/tcp
sudo ufw enable
sudo ufw status
sudo systemctl restart ufw

参考: ufwコマンドの使い方 - Qiita

ルータのポートフォワーディングの設定

80番ポートをRaspberry PiIPアドレスに!
→全世界に公開!


ドメインにアクセスすると次のような画面が表示される。
f:id:nixeneko:20210501222613p:plain

百葉箱の設置

センサー部分は百葉箱の中に突っ込む。どうせ家の蔭で日には当たらないので別にいらない気もするが。
もともとは防水プラボックスごと百葉箱のなかに突っ込むつもりだったが思ったよりでかかったので外に出すことにした。なのでもっと小さいやつで良かったかもしれない…

まともな百葉箱は高いのでレーザーカッターによるそれっぽいキットを使った。

この中型ウェザーシールド。

梱包はこんな感じで、側面の鎧戸(戸ではないが)だけ組み立てられた状態で来た。
f:id:nixeneko:20210501204048p:plain

仮組みしようとしたら手前の板の片方の穴が3mm下にずれていて嵌まらなかった。
f:id:nixeneko:20210501204154p:plain
…ので、ナイフで削って嵌まるようにした。

仮組。
f:id:nixeneko:20210501204608p:plain
手前の取り外せるようになっている窓が、扉みたいになっていないので、取り外しはやや面倒かもしれない。あまり頻繁に開け閉めしたい場合には向かないと思う。


2x4材に固定できるように背面板に穴を開け、接着し塗装した(塗装と接着を交互にやって上手い具合にやろうとした)。塗装は白の水性塗料を刷毛で2重~3重に塗り重ねた(塗料がなくなったのでやめた)。
f:id:nixeneko:20210501205340p:plain

防水プラボックスの上方、2x4材にネジとナットで固定し、底に開けた穴からセンサーを中に入れ、センサーを針金で固定した。
f:id:nixeneko:20210501210137p:plain

ついでにケーブルをナイロンクランプで固定している。
蓋をして完成。
f:id:nixeneko:20210501211020p:plain

材料費

合計17,600円程度。

他に送料、工具類、固定用のネジ類、2x4材等などは書いてないが5千円はかかってるかもしれない。
百葉箱除けば1万円しないと考えると、いい感じかもしれない。

感想

要素要素の技術は大したことない(というか、誰かがしっかり舗装してくれているのでそれを辿るだけでいい)が、組み合わさるとなんかすごいことやってる気がしてくる。

温度センサの誤差が±0.5℃なので結構大きめ。もっと高精度のやつが使えればいいんだけど、防水プローブに入ったやつが見つからなかったのでどうすればいいのかよくわからない。直接雨が当たらなければ別に良さそうな気もするが。

ラズパイはLinuxなので楽だが、Wifiと電源がないところでは使えないので、設置場所が限られる。バッテリーで数ヶ月動くデータロガーが作れればいいんだけど。

Windows UpdateでWindows 10を20H2に更新しようとしたら途中で止まった

まとめ: Windows 10をバージョン1909以前から20H2へアップデートする場合、Windows 10更新アシスタントを使う。

問題の説明

Windows Update配信されたアップデートで20H2に更新しようとしたら、61%になってから動かなくなった。半日放置しても変化なし。
ドライバなどを最新に更新したりしてやり直してみても同様。

どうやらWindows 10のバージョンが古かったので、更新でエラーが出てたらしい?
しかし何でこんな古いものがずっと動き続けてきたんだろうか。自動でアップデートがなされてても良さそうに思うが…。サポートが切れるから早よ更新しろ、という通知が来たので、更新されてないことに気付いた。

環境

  • パソコン ASUS UX310UQ
  • 更新前OS: Windows 10 バージョン1709
  • 更新後OS: Windows 10 バージョン20H2

解決策

バージョン1909以前から、バージョン20H2にアップデートする場合、Windows 10更新アシスタントを使うのが推奨されているらしい。

上のページから、「今すぐアップデート」をクリックして更新アシスタントをダウンロードする。

Windows Updateが動いているとインストールできないし、一方で勝手にWindows Updateが動き出すので、

  1. まず機内モードにして再起動し、
  2. Windows 10更新アシスタントを実行してから機内モード解除、

とすることで動いた。

そのまま無事更新された。よかった。

GASを使って作業開始・終了時刻記録Webアプリを作った話

概要

Google Apps Script (GAS)を使って、自分で使うための、作業開始・終了時刻を記録するWebアプリを作成した。

f:id:nixeneko:20210311005657p:plain
作成したアプリ
この記事にあるもの
  • GASによるWebアプリ作成の流れ
  • 作ったアプリのコード
ないもの
  • コードの書き方・解説

モチベーション

  • 作業時間(開始時刻, 終了時刻)を記録したかった
  • 開始時と終了時にボタンを押すだけで記録できるとよい
  • スマホで使えると良い

これを満たすのに、勤怠管理システムが使えるかと思い、無料で使える勤怠管理Webサービスを使っていた。しかし、一日に2回以上の勤怠データを登録することができず、自分の使いたい目的では使えないことが分かった。

そのため、自分で使うためのものを作ることにした。

要件

  • 「出勤」「退勤」ボタンが表示される(実際には「作業開始」「作業終了」だが長いのでこうした)
  • ボタンを押すとその時の時刻およびボタンに対応する「入」「退」のどちらかが記録される
    • 時刻の記録だけに特化する。作業時間の計算等は別にプログラムを書いてどうにかする

GASとは

Google Apps Script (GAS)とは、Googleサービス等と組み合わせて使える、Javascriptをベースとしたスクリプト言語で、Googleドライブとかで実行できる。また、GASを使ってWebサービスを開発したり公開したりできる。

Webサービスであればスマホで使えるのでいいだろうということでGASで作ることにした。

実装方針

  • 勤怠時刻データはそれ用のGoogleスプレッドシートを作成しそこに保存する
  • GASによるWebサービスでは、HTTPリクエストを受けると決められた関数がトリガーされる。GETとPOSTで別の関数となっている:
    • GET (doGet関数)では、ボタンを表示するページを返す
    • POST (doPost関数)では、時刻の記録を行う。記録した後はGETにリダイレクトする(POSTでページを表示した場合、リロードすると再度記録されてしまうため)

実際にやって動かしてみる

まず、Googleドライブを開く*1

記録用スプレッドシートの用意

先に記録用のスプレッドシートを用意する。

「新規」→「Google スプレッドシート」をクリック
f:id:nixeneko:20210310221702p:plain

  • スプレッドシートのタイトルを変更(任意)
  • 見出しを適当に入力。ここでは「時刻」「入退」「備考」にした。
  • シート名を入力。シート名は何でもいいがここでは"time_log"とした。後で使う。
  • スプレッドシートのIDをコピーしておく。後で使う。
    • スプレッドシートのURLがhttps://docs.google.com/spreadsheets/d/XXXXXXXXXXXXXXXXXXXX/editみたいな感じになっているので、XXXXXXXXXXXXXXXXXXXXの部分がIDになる。

Apps Scriptの作成

「新規」をクリック*2→「その他」→「Google Apps Script」をクリック
f:id:nixeneko:20210310215715p:plain

エディタが開くので、「無題のプロジェクト」と書かれている部分をクリックしてプロジェクトの名前を適当に変更する(任意)

実行の流れとしては、コードを変更する→保存する→デプロイする→ページを開いて確認という流れになる。

動作確認

最初にWebアプリを動かしてみる。エディタに次のコードを入力する。

function doGet() {
  const html = '<!DOCTYPE html><html><head><base target="_top"></head><body><h1>Hello World!</h1></body></html>';
  return HtmlService.createHtmlOutput(html);
}
デプロイ

保存ボタン「💾」をクリック、あるいはCtrl+Sを押す→「デプロイ」→「新しいデプロイ」をクリック
f:id:nixeneko:20210310225542p:plain

「種類の選択」の右の歯車「⚙」をクリック→「ウェブアプリ」をクリック
f:id:nixeneko:20210310225656p:plain

「デプロイ」をクリック
f:id:nixeneko:20210310225923p:plain

すると、ウェブアプリのURLが表示されるのでコピーして控えておく。後で使う。
f:id:nixeneko:20210310230247p:plain

ウェブアプリのURLを開くと次のように表示される。
f:id:nixeneko:20210310230440p:plain

コードを変更した際は、まず保存し、「デプロイ」→「新しいデプロイ」→「デプロイ」を繰り返すと変更が反映される。*3

アプリを動かしてみる

さて、こちらに作ったアプリを用意した。これを動かしていく。

「ファイル」の右の「+」をクリック→「HTML」をクリック
f:id:nixeneko:20210310231515p:plain

名前を「index」と入力して確定
f:id:nixeneko:20210310231609p:plain

index.htmlのコードを消して次の内容に書き換える:

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style type="text/css">
      button {font-size: 15vw; height: 30vw; width: 45vw;}
      input {width: 80vw;}
    </style>
  </head>
  <body>
    <h1>タイムカード</h1>
    <form action="<?= app_url ?>" method="POST" name="f">
      <div>
        <button type="submit" name="action" value="in" <?= in_disabled ?> >出勤</button>
        <button type="submit" name="action" value="out" <?= out_disabled ?> >退勤</button>
      </div>
      <div>備考: <input name="comment"></input></div>
    </form>
    <div>最終: <?= last_time_log ?></div>
  </body>
</html>

コード.gsの内容を次のコードに書き換える。app_url, spread_sheet_id, sheet_nameの値は先ほど控えた値に変更する。

const app_url = "ここにWebアプリのデプロイURLを入力"; //このアプリのデプロイURL
const spread_sheet_id = "ここにスプレッドシートのIDを入力"; //記録するスプレッドシートのID
const sheet_name = "time_log" //スプレッドシートのシートの名前

function getSheet(){
  const spreadsheet = SpreadsheetApp.openById(spread_sheet_id);
  const sheet = spreadsheet.getSheetByName(sheet_name);
  return sheet;
}

function getLastRow(){
  let sheet = getSheet();
  let last_row_idx = sheet.getLastRow();
  let values = sheet.getRange(last_row_idx, 1, 1, 3).getValues()[0];
  return values;
}

function addLastRow(time_str, in_out, comment){
  let sheet = getSheet();
  let last_row_idx = sheet.getLastRow();
  sheet.getRange(last_row_idx+1, 1, 1, 3).setValues([[time_str, in_out, comment]]);
}

function formattedDate(date){
  return Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss");
}

function doGet(e) {
  let html = HtmlService.createTemplateFromFile('index');
  html.app_url = app_url;
  let date, in_out, comment;
  [date, in_out, comment] = getLastRow();
  let date_str;
  if (date instanceof Date) {
    date_str = formattedDate(date);
  } else {
    date_str = date;
  }
  html.last_time_log = date_str + " " + in_out + " " + comment;
  if (in_out == "入"){ //最後が出勤なら退勤のみ表示
    html.in_disabled = "disabled";
    html.out_disabled = "";
  } else {
    html.in_disabled = "";
    html.out_disabled = "disabled";
  }

  return html.evaluate()
              .addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no')
              .setTitle('タイムカード');
}

function doPost(e){
  let action = e.parameter.action;
  let now = new Date();
  let date_str = formattedDate(now);
  const action_to_japanese = {"in": "入", "out": "退"};
  let in_out = action_to_japanese[action];
  if (!in_out) throw new Error('POSTパラメータが異常です'); //不正な呼び出し
  let comment = e.parameter.comment;
  addLastRow(date_str, in_out, comment); //書き込み

  // GETにリダイレクトする
  return HtmlService.createHtmlOutput(
    "<script>window.top.location.href='"+ app_url + "';</script>"
  );
}

入力したら保存し、デプロイをする。

認証

デプロイをしようとすると、スプレッドシートにアクセスするために認証が必要になる。

「アクセスを承認」をクリック
f:id:nixeneko:20210310232713p:plain

ウィンドウが開くので今使っているアカウントを選択
f:id:nixeneko:20210310233040p:plain

「このアプリは Google で確認されていません」と表示されるので「詳細」をクリック
f:id:nixeneko:20210310233341p:plain

「time recorder (安全ではないページ) に移動」をクリック(time recorderは設定したプロジェクト名)
f:id:nixeneko:20210310233612p:plain

「許可」をクリック
f:id:nixeneko:20210310233738p:plain

これでデプロイができる。一度やればOK。

ブラウザで開いて確認

次のような画面になる。スマホのサイズでちょうどいい感じ(だがパソコンで開くとすごいバランスになる)。
f:id:nixeneko:20210311005657p:plain

ボタンを押すとスプレッドシートに時刻が書き込まれることが確認できる。
f:id:nixeneko:20210311013206p:plain


これで完成。デザインは適当だが自分しか使わないのでヨシ!
スマホでアプリのURLを開けば実行できる。アプリを作成したのと同じGoogleアカウントでのログインが必要。
ちょっと読み込みが遅い気がする。


ほか工夫点。

  • 入退は交互に来るはずなので、ボタンは交互にグレーアウトするようにした。
  • 作業内容等を書けるようにコメント欄も作った。
  • スプレッドシートの最終行=最後に記録された内容を表示するようにした。

GAS引っかかりメモ

Googleアカウントを切り替えられない?

エディタを開く際、同時に複数のGoogleアカウントにログインしている場合、メインのアカウント以外に切り替えることができないようで、使いたいアカウント以外をログアウトする必要があるっぽい?

デプロイする前に保存するのを忘れて変更が反映されない

よくある。保存してデプロイし直す。

WebアプリのHTML内のJavaScriptから、ブラウザのアドレスバーにあるWebアプリのURLを取得

無理っぽい。iframeに入っていてかつクロスドメインになっているため。なぜ…

タイトルやmetaタグの設定

GASのWebアプリで表示されるページは、二重にiframeが入ったその中にアプリで設定したHTMLが表示される。そのため、HtmlOutputを作成する際のHTMLの中でタイトルやスマホ対応のviewportを指定するmetaタグを設定しても効果がない。

代わりにHtmlOutputオブジェクトの.setTitleメソッド、.addMetaTagメソッドを呼び出して、それぞれタイトルとメタタグを設定する。メソッドチェーンもできる。

return HtmlService.createTemplateFromFile('index').evaluate()
              .addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no')
              .setTitle('タイムカード');
リダイレクト

window.top.location.hrefにリダイレクト先のURLを代入するJavascriptコードを実行するHTMLを返せばいいっぽい。

  return HtmlService.createHtmlOutput(
    "<script>window.top.location.href='"+ app_url + "';</script>"
  );

参考: google apps script - Automatically Redirecting to a Page - Stack Overflow

まとめ

  • 自分だけ使えるWebアプリをタダで作って使えるので便利*4
  • Googleドライブ上のデータを読み書きできる(認証が必要)
  • 自分しか使わないので必要なものを最低限だけ実装すればいい
    • コーナーケースに対するエラー処理もしなくていいかもしれない(運用でカバー)
  • 100行もコードを書かずにできた

*1:使いたいアカウント以外をログアウトしておく必要があるかもしれない。

*2:画像ではマイドライブ▼をクリックしてるけど出るメニューは同じ。ファイル一覧のペインを右クリックしてもよい。

*3:エディタで「デプロイ」→「デプロイをテスト」とすると、最新のコードをテストできるURLが得られるので、開発に有用かもしれない。

*4:設定次第で、他人に使わせたり、一般公開することも可能。

Scrapbox記法→はてな記法 変換器をつくった

私はアイデアや下書きをまとめるのにScrapboxを利用している。一方で、情報をまとめて公開するのにははてなブログを主に利用している。
はてなブログでは「見たまま」「はてな記法」「Markdown」の三種類の編集方法が選べるが、私は「はてな記法」を使っている。理由は慣れである。

Scrapboxの記法もはてな記法も、簡潔な軽量マークアップ言語であり、プレーンテキストで編集できるので便利ではあるものの、それぞれに互換性がないので、相互に情報を移動させたければ変換が必要である。

しかしながら、そのような変換器が探しても見つからなかったため、Scrapboxからはてな記法へと変換するコンバータ(変換器)を作成した。

コンバータのページ

制限

  • 完全な一対一の変換はできないので、変換結果をはてなブログに投稿する前に確認して修正する必要があると思う。たたき台としての利用を想定している。
  • コードブロックのシンタックスハイライトは、とりあえず私が使う言語だけ対応させた。Scrapboxはてなブログが対応している他の言語が必要なら教えてください。
  • 画像も変換に未対応な種類があるかもしれません。
  • 本コンバータの逆変換、つまりはてな記法Scrapboxへの変換は作る気はない。それを私は必要としていないので。

やや特殊な変換

  • 次の3つは単体で表示するためにはコードブロックに入れる必要があるため、その一文字だけがインラインコードになっている場合、<code>~</code>で囲むことはしない。
    • `[`[
    • `]`]
    • `\```
  • 見出し。Scrapboxには見出し専用の記法はないので、行頭にインデントがなく、かつ文字を大きく表示する記法単体しか行に存在しない場合に見出しとした。
    • [**** 大見出し]* 大見出し
    • [*** 中見出し]** 中見出し
    • [** 小見出し]*** 小見出し

ソース

何をやってるのかはソース読むと分かるかもしれません。行きあたりばったりで書いてるので読んでわかるかは分かりませんが…。
github.com

テスト用データ

テストに使ったScrapboxページのコードを載せておく。変換結果を表示して見比べてみると良いかと思う。

Scrapbox表記テストページ
変換器のテストに使います。
[**** 大見出し]
[*** 中見出し]
[** 小見出し]
[* 太字1][[太字2]]ふつうの文字[** ややでかい文字][*** もっとでかい文字][**** 更にでかい文字][***** もっとでかい文字]
[/ italic] [- stroke] [*/* bold bigger italic] [**-/ bold bigger stroke italic] [_ underline] [_/* underline italic bold]

数式[$ \sin (x)]
> 引用1
>引用2
 >引用3
$ cat commandline1
% cat commandline2
	$ cat commandline3
`コード`記法。`[`~`]` バッククォート自体は`\`` と入力する。`\` \` 
code:js
	function test(){
   console.log("Hello World!");
 }
code:py
 import numpy as np
 np.array([])
code:test.md
	# hoge
	## hogehoge
table:tabletitle0
	1111	2222	3333	4444
	aaaa	bbbb	cccc	dddd

	箇条書きlevel1
		箇条書きlevel2
			箇条書きlevel3
				箇条書きlevel4
	1. 数字
		2. 数字
			3. 数字
画像
[https://nixeneko.sakura.ne.jp/hatenablog/20180316_oembed/simarin.jpg]画像1
[[https://nixeneko.sakura.ne.jp/hatenablog/20180316_oembed/simarin.jpg]]画像2
URL
https://www.yahoo.co.jp
[Yahoo https://www.yahoo.co.jp]
[https://www.yahoo.co.jp Yahoo]
[hoge https://www.yahoo.co.jp https://www.google.co.jp]
[https://www.google.co.jp https://www.yahoo.co.jp https://www.yahoo.co.jp]
ほかどうでもいいやつ
[ページタイトル]
[ページタイトル.icon]

アニメキャラの目のハイライト消しデータセット

次のブログ記事で利用したデータセットを公開します。

データセットの内容の一部。左側に目にハイライトのある画像、右側にハイライトのない画像を並べている。

説明

アニメの顔の画像で、目にハイライトがあるもの(オリジナル)と、そこから目のハイライトを塗りつぶして消したものとのペアです。500ペアあります。
ペアは完全に1対1の対応がとれているので、pix2pixなどの1対1で対応がとれた教師データを必要とする画像変換モデルに適しています。

画像は2017年冬(1月~3月)に放送されていたアニメのスクリーンショットから顔を切り出したもので、正方形で一辺が256px以上720px未満となっています。


このデータセットは、日本国の著作権法(2021年1月1日施行)の第三十条の四に基づいて公開します。
参考: 進化する機械学習パラダイス ~改正著作権法が日本のAI開発をさらに加速する~ | STORIA法律事務所

Twitterハッシュタグの仕様について

f:id:nixeneko:20210130202127p:plain

まえがき

Twitterに"#᥋ᵍᶜₒ𝖛ịⅆ"というハッシュタグが流れているのを見た。内部的には"#5GCovid"と等しいようだった。タグの指す内容は置くとして、これが同一視されてるのは奇妙な気もした。
これは、他にも同様のハッシュタグを作れるのでは?と思って、変換器を作った。
nixeneko.github.io

作成する際にTwitterハッシュタグの仕様を探って、なんとなく雰囲気がつかめたのでメモとして残しておく。

Twitterハッシュタグの仕様

(2021/01/29現在のため、変更される可能性がある)

  • #で始まり、ハッシュタグに使えない文字が来るかツイート末まで続く
  • #はツイート頭にあるか、#の前にはスペースが必要
  • $で始まるハッシュタグもあるが、ASCII英字6文字まで、とか制限が強い

ハッシュタグに使える文字

  • Unicode 7.0に存在する文字である必要がある(8.0以降で追加された文字はハッシュタグにならない)
  • ハッシュタグに使える文字は、基本的には、次のgeneral categoryをもつUnicode文字であるようだ:
    • Lu……Letter, Uppercase
    • Ll……Letter, Lowercase
    • Lt……Letter, Titlecase
    • Lm……Letter, Modifier
    • Lo……Letter, Other
    • Mn……Mark, Non-Spacing
    • Mc……Mark, Spacing Combining
    • Me……Mark, Enclosing
    • Nd……Number, Decimal Digit
  • このほかにも個別に追加されていると思われる記号がある。例:
    • 「_」アンダースコア
    • 「・」中黒
    • 「゠」ダブルハイフン U+30A0 Katakana-Hiragana Double Hyphen
  • 数字やマークだけではハッシュタグにはならない

ハッシュタグの同値判定の仕様の推測

  • 大文字小文字を区別しない
  • 文字を同一視するためにUnicode正規化が施されているはず。
    • 結合文字を処理する都合から互換分解(NFKD)だと思われる。ツイート自体にはNFCが使われている
    • あるいは、Unicode Collation Algorithmとかを一部利用しているのかも?
  • ある種の結合文字(combining marks)は無視される
    • "#天デ部"と"#天テ\u0301\u3099部"(Combining Acute + Combining濁点)が同一視される(\uXXXXはUnicode文字を表す)
    • 無視される結合文字の一覧は後掲する
    • ダイアクリティカルマーク合成済み文字の場合は、正規化により分解されて、マークが除去される
  • ひらがな・カタカナの、濁点・半濁点つきの文字とそうでない清音の文字は区別される。また、ひらがなとカタカナも区別される
    • (Unicode正規化の互換分解で半角濁点U+FF9Eは合成用濁点U+3099に変換されるので以下の話は当然なので消した)
    • 清音の仮名の次に半角濁点・半濁点が来ると、濁音・半濁音の仮名として扱われる(ひらがなカタカナともに) (間に一部の結合文字が挟まっててもよさそう)
    • 仮名と(半角または合成用)濁点・半濁点の間にU+034F COMBINING GRAPHEME JOINERが入ると濁点が消えるっぽい。なぜ…?
  • ある種の10進数字は、ASCII文字の数字と同一視される
    • Decimal digit valueの示す数字と同一視されるのかと思ったが、Decimal digit valueが存在していてもASCII数字と同一視されないものがあった。次である:
      • U+0de6-U+0def SINHALA LITH DIGIT
      • U+a9f0-U+a9f9 MYANMAR TAI LAING DIGIT
      • U+104a0-U+104a9 OSMANYA DIGIT
      • U+11066-U+1106f BRAHMI DIGIT
      • U+110f0-U+110f9 SORA SOMPENG DIGIT
      • U+11136-U+1113f CHAKMA DIGIT
      • U+111d0-111d9 SHARADA DIGIT
      • U+112f0-U+112f9 KHUDAWADI DIGIT
      • U+114d0-U+114d9 TIRHUTA DIGIT
      • U+11650-U+11659 MODI DIGIT
      • U+116c0-U+116c9 TAKRI DIGIT
      • U+118e0-U+118e9 WARANG CITI DIGIT
      • U+16a60-U+16a69 MRO DIGIT
      • U+16b50-U+16b59 PAHAWH HMONG DIGIT
  • 他にも、特殊な同一視がみられる。個別にルールが追加されているのかもしれない。次のような例を見つけた:
    • "Å"→"AA", "å"→"aa"
    • "Ä"→"AE", "ä"→"ae"
    • "Æ"→"AE", "æ"→"ae"
    • "ı"→“i" (トルコ語用のdotless iの対応)
    • "Ö"→"OE", "ö"→"oe"
    • "Ø"→"OE", "ø"→"oe"
    • "Ü"→"UE", "ü"→"ue"
    • "ß"→"ss" (エスツェット)

無視されるCombining Marksの一覧を次に挙げる。抜けがあるかもしれない。

ignorable_marks = [
    "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0305", "\u0306", "\u0307", 
    "\u0308", "\u0309", "\u030a", "\u030b", "\u030c", "\u030d", "\u030e", "\u030f", 
    "\u0310", "\u0311", "\u0312", "\u0313", "\u0314", "\u0315", "\u0316", "\u0317", 
    "\u0318", "\u0319", "\u031a", "\u031b", "\u031c", "\u031d", "\u031e", "\u031f", 
    "\u0320", "\u0321", "\u0322", "\u0323", "\u0324", "\u0325", "\u0326", "\u0327", 
    "\u0328", "\u0329", "\u032a", "\u032b", "\u032c", "\u032d", "\u032e", "\u032f", 
    "\u0330", "\u0331", "\u0332", "\u0333", "\u0334", "\u0335", "\u0336", "\u0337", 
    "\u0338", "\u0339", "\u033a", "\u033b", "\u033c", "\u033d", "\u033e", "\u033f", 
    "\u0340", "\u0341", "\u0342", "\u0343", "\u0344", "\u0345", "\u0346", "\u0347", 
    "\u0348", "\u0349", "\u034a", "\u034b", "\u034c", "\u034d", "\u034e", "\u034f", 
    "\u0350", "\u0351", "\u0352", "\u0353", "\u0354", "\u0355", "\u0356", "\u0357", 
    "\u0358", "\u0359", "\u035a", "\u035b", "\u035c", "\u035d", "\u035e", "\u035f", 
    "\u0360", "\u0361", "\u0362", "\u0363", "\u0364", "\u0365", "\u0366", "\u0367", 
    "\u0368", "\u0369", "\u036a", "\u036b", "\u036c", "\u036d", "\u036e", "\u036f", 
    "\u0610", "\u0611", "\u0612", "\u0613", "\u0614", "\u0615", "\u0616", "\u0617", 
    "\u0618", "\u0619", "\u061a", "\u064b", "\u064c", "\u064d", "\u064e", "\u064f", 
    "\u0650", "\u0651", "\u0652", "\u0653", "\u0654", "\u0655", "\u0656", "\u0657", 
    "\u0658", "\u0659", "\u065a", "\u065b", "\u065c", "\u065d", "\u065e", "\u065f", 
    "\u0670", "\u06d6", "\u06d7", "\u06d8", "\u06d9", "\u06da", "\u06db", "\u06dc", 
    "\u06df", "\u06e0", "\u06e1", "\u06e2", "\u06e3", "\u06e4", "\u06e7", "\u06e8", 
    "\u06ea", "\u06eb", "\u06ec", "\u06ed", "\u093c", "\u094d", "\u3099", "\u309a"]

ひらがな・カタカナ用の合成用濁点・半濁点も無視される。ものの、濁点や半濁点がついた仮名をそうでない仮名と同一視することはしない(「テ」と「デ」は別)。そのため、濁点や半濁点のつき得る仮名についた合成用濁点・半濁点は削除しない。
これは、Unicode正規化により、NFCなどで合成してから合成用濁点・半濁点を除去するという順序にするとうまくいくと思う。

考えられるハッシュタグの同値判定の流れ

同値判定と書いてるけど、collation (照合)とよぶものらしい。そのために、Unicode Collation Algorithm (UCA)という標準が存在し、それをカスタマイズして使っている可能性もあるが、よくわからない。

さて、ともかく次のような流れで再現できると思われる:

  • 仮名+半角濁点・半濁点→濁音・半濁音の仮名に変換(Unicode正規化(NFKD/NFKC)で普通の仮名になるので略)
  1. Unicode正規化(NFKD)による互換分解
  2. 取り除けるマークを取り除く(合成用濁点・半濁点についてはここでは残す)
  3. 小文字にする
  4. "ı"→“i"、"ß"→"ss"などのルールの適用
  5. Unicode正規化(NFC?)による合成
  6. 合成されなかった合成用濁点・半濁点を除去

難読化の仕組み

上記したハッシュタグの同値判定を逆方向にたどっていくと、最終的に同値に判定される様々なバリエーションが得られる。詳しくはソースでも見てください。行きあたりばったりなので見てわかるかは知らないですが…
github.com