おひとり様ActivityPubサーバーにブースト実装

twitter(現X)に代表されるSNSの一番の特徴はリツート(ブースト、リポスト)
これがあるおかげで?せいで?SNSへの投稿は拡散力を持つことになった。良い悪いはおいといて、ウチのおひとり様APサーバーにも実装してみた。
考えなきゃいけない一番の優先順位は。
「デマの拡散に加担してはいけない」
なので、ブーストはブースト取り消しの実装と同時。
ブーストはActivityPubだとタイプAnnounceのjson。対象となるNoteのIDをobjectに入れる。
宛先の「cc」には自分のfollowersに、対象となるNoteの投稿者も含めておく。
{"@context": ["https://www.w3.org/ns/activitystreams", {"Hashtag": "as:Hashtag"}],
"type": "Announce",
"id": "https://tokoroten.doncha.net/t2aki/announce/TIMESTAMP",
"actor": "https://tokoroten.doncha.net/t2aki",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [DELIVER-LIST],
"object":"NoteID"}
Announceの取り消しはタイプUndoのjsonに、Announceに使ったjsonをobjectにそのまま入れる。
{"@context": ["https://www.w3.org/ns/activitystreams", {"Hashtag": "as:Hashtag"}],
"type": "Undo",
"id": "https://tokoroten.doncha.net/t2aki#UUID",
"actor": "https://tokoroten.doncha.net/t2aki",
"object":ANNOUNCE-OBJECT}
以上のjsonを自分のフォロワーさんのinboxに署名付きpostでリクエストをすれば、ブースト・ブーストの取り消しとなる。
これだけっちゃこれだけなんだけど、ブーストはスクリプトじゃないところに問題があるんだよなあ。
ブーストを実装したいと思ったのは。
いま、フォロワーさんの数が16人ほどで拡散力もなにもないんだけど、それでもイベントのお知らせや、発売の宣伝なんかは、微力ながら協力できればなあ、というのが動機。
せっかくSNSなんだし。
ブーストはヤバイなあと思うのは。
デマの拡散に加担する可能性、というのがもちろん一番で。
それ以外というか、他人の投稿を拡散することで自分が何かを語ったつもりになる、という乗っかりの承認欲求、自己顕示欲を得られるところ。
自分の頭で考えて自分の言葉で語ることをしなくなる、簡単に闇落ちする(自戒をこめて)
実装はしたけど、ブーストするのは基本的にイベントや発売告知だけにする。
また、ウチのおひとり様は、他人のデータを持たない、というのが原則。
なので、Annouceの投稿も期限つき。最長7日間にした。
イベントなんかは終わってたら残念だし、発売関連は初動に少し貢献できれば十分かなあ、と。
ひとつ何かを実装するのに、やっぱりけっこういろいろ考慮しなきゃいけないことってあるもんだなあ(今さら
自分のとこだけのことならどうでもいいんだけど、ActiviyPubで繋がるわけだから、そりゃそうかという話。
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」
おひとり様ActivityPubサーバー自作実装メモ

ActivityPubを通じてMastodonなどFediverseと呼ばれるネット連合からアカウントとして認識されるように最低限のサーバー構成を作ったのが7/12。その後、秘密鍵・公開鍵を使えるperlのモジュールをlolipopレンタルサーバーにインストールできたことで、それっぽいものにしあがって、実際に運用して今日に至る。
「ため池」
↑perlで自作実装したおひとり様ActivityPubサーバー
そもそもMastodonやMisskeyなどのサーバーを立てるスキルがない。わたしには難易度が高すぎる。
自分のわかる範囲で自作実装したので、間違ってる・勘違いしてるところもあるかも知れないけど、今のところ意図通り。
この雑記帖と同じレンタルサーバーに展開して追加の費用なども必要ない。
自作とはいえ、スグ忘れるのでまとめてメモしておこう、というのがこのエントリ。
5分以上前の自分はアカの他人。
Fediverseからアカウントとして認識されるために必要なもの
1)nodeinfo(json)
サーバー情報。
2)host-meta(xml)
webfingerのURL。
3)webfinger(json)
ユーザー情報のURL。
4)actor.json(json)
ユーザー情報。type:Personのjson。
上から順番にアクセスされて4のユーザー情報にたどり着けばActivityPubサーバーのアカウントとして認識される。
1と2はなくても大丈夫らしいけど、webfingerのURLは「/」だったり「/.well-known/」だったりするので、2の「/host-meta」で教えてあげる。
「ActivityPubを使ってFediverseにたどり着く」
↑各ファイルの詳細。
おひとり様サーバーとして動作しているスクリプトは2つだけ。
・飛んでくるリクエストをさばく門番的スクリプト
・タイムラインを表示、投稿、管理するスクリプト
【飛んでくるリクエストをさばく門番スクリプト】
ActivityPubでの会話を求めて飛んでくるリクエストはいろいろ。
アカウント情報を取得するためにactor.jsonをリクエストされるし、ActivityとしてCreate(投稿)Follow(フォロー)などのリクエストが飛んでくる。
まず「.htaccess」を設置
rewrite ruleでパラメータをつけて門番スクリプトにリダイレクトするところからスタート。
リダイレクトされてリクエストを受けつける門番スクリプトの仕事は以下。
サーバー情報やユーザー情報はGETでリクエストが飛んでくる。
リクエストされたURLに応じて、門番は用意してある静的ファイル、jsonやxmlファイルを返す。
ActivityPubのjsonファイルは「Content-Type: application/activity+json; charset=utf-8」をヘッダにつけるのが必須。
followerやfollowingにアクセスされた時はfollower、followingを記載したテキストファイルを読んで、静的ファイルにそのリスト情報をくっつけて返すようにした。
「最低限お一人様ActivityPubサーバーにちょい足し 」
↑フォローリスト情報をつけ足した経緯。
Activityリクエストは「ユーザー名/inbox」にPOSTでリクエストが飛んでくる。
これはまとめて門番で受け取って。
・HTTPヘッダ類
・content(ボディ)
これをひとつのログファイルとして、所定のディレクトリに保存する。
ボディ=jsonを読んで、CreateだのFollowだのUndoだのログファイル名の一部にしておく。
門番スクリプトの仕事はここまで。
ヘッダ類もログファイルに収録するのは。
公開鍵とhttp signatureで、飛んできたリクエストが正しく署名されているか検証する必要がある。
Mastodonなんかはたぶんリクエストが来たらその場で検証までしてるはずで、ログファイルなど必要ないと思う。
わたしの場合、まだよくわかってないことが多くて、ログファイルで確認したいことがあるので、そのためにログファイルを残している。
ActivityのPOSTリクエストのうちいくつかは今時点スルーしている。
仕様的にはMUSTとかSHOULDだったりするものもあって、対応が必要と思われるけど、ウチの運用的にそれ必要?と微妙なもの。
Like
いわゆる「いいね」は門番のところで破棄。
Announce
ブースト(リツイート)はもし自分の投稿のブーストだったら破棄。
どちらも注目度ということなんだろうけど、それだったらサーバーのアクセスログを眺めるのが正解。
Delete
投稿の削除、だけならともかく、見知らぬアカウントの削除がけっこうなボリュームで流れてくる。なので、とりあえずDeleteも破棄。下記するけど、基本ウチは削除が前提。
【タイムラインを表示、投稿、管理するスクリプト】
あまり考えず「ため池」という名前にした。
Mastodonでいうところの「ローカルタイムライン」のために投稿用のデータベースを用意。
といっても、テーブル2つだけ。それも1つはログインに使うだけのもの。投稿用のテーブルはテキストと、画像のURLや幅・高さぐらいを記録するメモ帳。
投稿するには重複しない一意のIDが必要で、それにはデータベースが便利に使える…ほぼそのため。
おひとり様サーバー、ユーザーはわたしだけ。サーバーに登録しているユーザーの投稿を表示する「ローカルタイムライン」は、わたしの壁打ちとなる。
フォローしたひとの投稿を表示する「ホームタイムライン」はファイルで管理。
自分以外、ひと様のデータを持たないのが原則。ひと様のデータを溜め込むといろいろ考えなきゃいけないことが出てくるから。
ため池にアクセスすると、門番スクリプトが保存したログファイルを開いて。
まずはHTTP SignatureのVerify。
actor情報をリクエストして、公開鍵を取得。公開鍵を使ってログファイルのヘッダ情報にあるSignatureを検証する。
Signatureに問題がなければ、Activityに応じた所定のフォルダに、所定の命名規則で名前をつけたjsonファイルを保存。元のログファイルを削除。
「RSAモジュールで公開鍵と秘密鍵」
「HTTP Signatureをlolipopレンタルサーバーで作成」
↑秘密鍵、公開鍵について
タイムライン用に保存するjsonファイルはCreateかAnnounceのNote
・上限20個
・期間1日
ディレクトリのファイルを読んで制限以上になっていたら削除。
フォローイングが30人を超えてると、読まないまま削除される方が多いし、さらにフォローは増やすつもりなので、読む・目にする投稿の方が圧倒的に少なくなる。知らない間に流れてきて知らないうちに削除されている。
でも、SNSはそのぐらいで適正だと思う。
その時その場で見たものがすべて。「袖すり合うも他生の縁」だ。
「ホームタイムライン」用に保存するjsonファイルは「to」に「Publish」(全公開)が指定されているもの限定。
この「ホームタイムライン」を表示するにはログインが前提。つまり、見ることができるのはわたしだけだ。
ホームタイムラインの投稿にフォローしたひとのアイコンや名前を表示させるために、actor.jsonをリクエストしてpersonを取得してそこからアイコンのURLや名前を取ることになる。
これだけのためにリクエストが生じるんで、ホームタイムラインの表示が重い。
本当だったら非同期で情報を更新したいところだけど、Javascriptとか面倒くさいし、メンテやエラーの特定にあっちもこっちもになってコスト高なので却下。手動更新。
見るのが自分ひとり、一般公開はしないということから、表示が重いのは我慢、また、同じ理由でいくつかのリクエストはスルーしてもいいかな、と。
MentionとFollowは別扱い。
両方とも確認してから返事するなりフォロー返しするなりしてから手動で削除することにした。
Follow
タイムラインに表示するのが20個までなので弾幕系というか投稿数が多いひとをフォローするとタイムラインがそのひとひとりに占拠されてしまうので、事前確認することにした。
FollowをAcceptしたら、Followのjsonを保存して、followersのリストに登録する。
Following、こちらからフォローする場合も同じ。Followingに使ったjsonを保存して、followingのリストに登録する。Followingのjsonを保存するのは、フォロー解除するUndoのために、元になったjsonが必要だから。uuidでIDを作ることにしてしまって、残しておかないと元のjsonを再現できないというオチ。
Mention
連絡先としてサイトや名刺など今まではtwitterのアカウントを入れたんだけど、twitterがなくなったんで、こっちを「連絡先はこちら」にしたい。Mentionを見落としちゃまずいんで、これも確認からの手動にした。
[09/21 10:05:12]
リクエストとして飛んでくるブーストは対応済みで、こちらからブーストのリクエストを飛ばすのも実装してみた。
「おひとり様ActivityPubサーバーにブースト実装」
いろいろ考えなきゃいけないことあった(改めて)
以下のサイトを参考にさせてもらっておひとり様ActivityPubサーバーを実装することができた。感謝深謝。
「NetlifyとSupabaseでほぼ静的なActivityPubサーバ」
「Fediverse入門―非中央集権型SNSサーバを作ろう!」
「田舎の昼のサイレンbotをActivityPubで実装する(マストドンにアカウントを認識してもらう編)」
「ActivityPubの実装についてのメモ」
ActivitiPub本家本元。
「ActivityPub」
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」
おひとり様ActivityPubサーバーにタイムライン追加

発信専門でいいかと思ってた「おひとり様APサーバー」をしばらく使ってて、やっぱりタイムラインが欲しくなった。フォローしたひとの投稿を表示する、Mastodonでいうところのホームタイムライン。
結局、投稿を閲覧するのにあっちのMastodonこっちのfirefishと行ったり来たりするのが面倒くさくなった、ということもあって、ここだけで完結できればそれに越したことはない。
とはいえ、要求される機能を満たすのはなかなかしんどいので、ここも必要最小限で追加してみた。
たぶん、すぐ忘れるのでメモ。5分以上前の自分なんてあかの他人だしなあ。
表示させるのは「to」に「Public」の指定のあるもの=全公開OKなものだけ(公開範囲がいろいろあって、全部を振り分けするのは面倒くさいし事故の元)
そして保持するのは1日だけ、最大個数20個まで(設定ファイルで制御)
データベースは使わない。ログファイルを振り分けて保存して、規定の期間個数に達したら古いものから削除する。
自分以外のひとのデータを持つのはいろいろ考えなきゃいけなくて、削除してしまうのは、そのリスク回避もある。データベースでも同じことだけど、ただのファイルだと、何かあったらFTPでログインしてその場で削除すればいいしね。慌ててデータベースにdeleteを投げるのは事故の元。
フォロー数が増えたら、この程度のデータ保持数だと見られない投稿の方が全然多くなる。
でもSNSなんて「袖すり合うも他生の縁」その時その場での関わりでいいと思ってるんで問題はない。むしろ本来こういうカタチが正しいと思ってる。
Mentionだけは別扱い。「連絡先はこちら」というのでこれまでtwitterのアカウントを使ってたんだけど、twitterがなくなったのでどうしたもんかと思ってたところ。今後は「連絡先はこちら」におひとり様APサーバーを使う。
「@t2aki@tokoroten.doncha.net」
↑これを検索してもらえれば、おひとり様APサーバーのアカウントが表示されて、そこにメッセージを飛ばしてもらえればわたしに届く。
もちろん、MastodonやMisskeyなんかにログインしている・アカウントが必要となる。twitterやfacebookと違ってハードルは高い。これについてはそのうちMastodonなんかが当たり前になればハードルは下がるし、そもそも、公開する連絡先なんて多少ハードルがあるぐらいでちょうどいい。
MentionはNoteの中のtag配列に「type:Mention」のブロックがあったらそこに宛先も記載されている。それで判定できる。
Mentionは、ログインしてないと見られない=見られるのはおひとり様サーバーのわたしだけ。
自分の投稿に対するLikeや、自分の投稿をブーストされたら、それらもリクエストが飛んでくる。
ご褒美とか承認欲求という言葉になるんだろうけど、ここは割りとどうでもいいので、それらのリクエストは受けた時点で破棄。
だいたい、こんなのはサイトのアクセスログを見ればわかることで、そっちの数字の方が確実だ。
本や雑誌でいうと、評価よりも実売部数、というのを肌身に染みて思い知らされてきたしね。
どうでもいいごたくを並べたけど。
こういったフロント側、閲覧側はかなりうっとーしーことがいろいろ。
タイムラインというからにはリアルタイムで表示を更新するのが当たり前。だけど、同期にしろ非同期にしろJavascriptなんかを使って閲覧してる裏でデータを確認取得しなきゃいけない。
飛んでくるリクエストを捌く、入口で門番をしてるスクリプトがあるのでデータが来たことはそれこそリアルタイムで検知している。
でもJavascriptは触りたくない。メンテが大変なのだ。どこが原因なのか特定するのにこっちまで見なきゃいけないのは労力に見合わない。
てことで、リアルタイム更新は却下。手動F5にした。
そんなオレオレAPサーバーは
「ため池」https://tokoroten.doncha.net/tameike.pl
絶賛壁打ち中です。
Noteの公開範囲についてはfedibird管理人ののえるさんの投稿
https://fedibird.com/@noellabo/110573926847901369
「ToにPublicが指定されていれば『公開』」
を参考にさせていただきました。ありがとうございます。
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」
HTTP Signatureをlolipopレンタルサーバーで作成

ActivityPubでPOSTするには電子署名が必要。
というエントリを書いたけど、WEBでサーバー同士でやりとりしたり、WEBサービスのAPIを利用したり、電子署名が必要なのはActivityPubに限らず。
ちらほら書いてきたように、lolipopレンタルサーバーにはperlで電子署名をする時によく使われるモジュール「Crypt::OpenSSL::RSA」がインストールされていない。
ということは。lolipopではHTTP Signatureを作ることができない「Cryptが使えないんじゃ話にならない」ということになる。
しょうがないんで、ローカルにcpanから「Crypt::OpenSSL::RSA」をインストールして、ActivityPubにPOSTする時はローカルのスクリプトを使う、という急場しのぎ。
これはこれで、投稿するのにひと手間あった方が事故も起こさなくていいかな、と思ってやってたんだけど、やっぱりサーバー上だけでやれるならラクちんなことは確か。
なもんで、グーグル先生にお願いしてみたら
「Crypt-Perl-0.38」https://metacpan.org/pod/Crypt::Perl
↑pure perlのCryptモジュールがあった。
インストール時にコンパイルが必要なモジュールはOSやファイル構成、環境に依存するけど、PurePerlというのはperlだけで作られているので、perlさえあればサーバーにそのままアップロードして使える。
さっそくダウンロードして展開。テストスクリプトで試したところ、依存関係で必要なモジュールがあったのでそいつらもダウンロードして展開。
Class-Accessor-0.51
Bytes-Random-Secure-Tiny-1.011
Convert-ASN1-0.34
Crypt-Format-0.12
(あと、たぶん以前amazonのAPIがらみでインストール済みのpure perlのDigest::SHAも必要)
当然ながら(?)これらのモジュールもpure perlなので、コンパイル不要、展開するだけで使える。
サーバーの適当なところ、例えば「lib」というディレクトリを作って展開したファイル群をアップロードするだけ。
lib/Bytes/
lib/Class/
lib/Convert/
lib/Crypt/
スクリプトで use lib 'lib'
などとやればlolipopレンタルサーバーでもCrypt RSAが使えるようになる。
いや。ぶっちゃけ。これらモジュールの作者のみなさんには大感謝。大袈裟じゃなく狂喜乱舞小躍りだった。ほんとうにありがとうございます。
マジperl使いすげー。perl話者が減ってるだろう昨今、凄腕のperl使いはワシントン条約で保護すべき存在。
使い方は「 RSAモジュールで公開鍵と秘密鍵 」とほぼ同じ。
秘密鍵を使う
my $privatekey = Crypt::Perl::RSA::Parse::private($private-pem);
公開鍵を使う
my $publickey = Crypt::Perl::RSA::Parse::public($public-pem);
Signatureを作る。
my $sign = $privatekey->sign_RS256($sign-str);
encode_base64($sign, "");
Signatureを検証する。
my $decoded = decode_base64($sign);
$publickey->verify_RS256($sign-str, $decoded);
あとは。
こうすると、思いつきから投稿放流までが直結するので、事故を起こさない自制心が必要となるです、はい。
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」
ActivityPubサーバの終わらせ方

おひとり様ActivityPubサーバーというか、最低限のオレオレAPサーバーを立ち上げた、のはいいけど、これの終わらせ方も調べないとダメ。
どんなアプリも起動方法と終了方法はマニュアルに必須の情報。
もうこのサーバーは終了しましたというお知らせを出せばいい、とのこと。
サーバー(サイト)へのアクセスに対して
「410 gone」(もう存在しません)を返す。
.htaccess
RewriteEngine On
RewriteRule .* - [G]
これで、サーバー(サイト)は終了しました、というお知らせになる。
いつまでこのお知らせを出せばいいのか。
特に決まりごとはない。
原則、サイトがある限りこの状態にすればいいということだけど。
このサイトはもう存在しませんよ、というのが周知されればOK…いや、周知されたかどうか知る方法はない。なので、根拠なく数ヶ月ぐらいでいいんじゃないか、とも言われている。
DNSの反映にしてもすべて行き渡るのにどのぐらいかかるのか知るよしもなしで、ネットがからむといきなり運任せというか神さまだけが知っている、という状態なのがメタな話で閑話休題(それはさておき)
サーバーのドメインを維持し続けるならともかく、サーバーもドメインも削除することもあるだろうし、その場合はその削除までのお知らせ掲載期間として数ヶ月もあればいいということかな。
サーバー(サイト)終了のお知らせが必要な理由は。
おひとり様APサーバーといっても、Fediverseに繋がることになる。
一度なんらかのアクションを起こすと、おひとり様APサーバーが、他のサーバーからリクエスト先として認識される。そうすると投稿やフォローはもちろん、各種Activityのために他のサーバーはリクエストを飛ばしてくる。
ところが、サーバー(サイト)をお知らせなしに終わらせると。
他のサーバーは存在しないサーバーがメンテで落ちてるのか本当になくなってるのかわからない。しょうがないんで、リクエストを再送しようとする。これが負荷になる。ネット全体に悪影響となる大きな負荷ではないんだろうけど、それでもチリツモ。
なので、ちゃんと終了のお知らせ掲載は必要。
どれだけの数のサーバーに繋がるのかは、それこそ神のみぞ知る領域。気軽手軽におひとり様APサーバーだっ!とか言ってもひとはひとりで生きるわけじゃない、とも。ActivityPubで繋がることの魅力でもあるし、そこに生じる責任とか、やらなければならないことの自覚も促されるところ、だ。
わたしみたいにミーハーな勢いだけでおひとり様オレオレAPサーバーとか言ってはしゃぐだけじゃダメです(自戒)
始めたものを終わらせる準備はしておく。
お盆だしね。
参考にさせていただいたサイト。
いつかサーバーを閉じるとき 〜お金をかけずに 410 を返す方法〜
あまり手間も費用もかけずに410 Goneを返してみる
RewriteRule Flags
サイトを完全に削除する方法
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」
最低限お一人様ActivityPubサーバーにちょい足し

ActivityPubを話すことで、Fediverse連合空間からアカウントとして認識されて、FollwやFollow受付、Noteの投稿ができるようにした。
最低限実装のお一人様サーバーなのでいろいろ手抜き。
フォロワー管理もそのうちのひとつ。followersへのアクセスに対して中身はからっぽで、ステータス200を返すだけでも特に問題はないということだし、何も用意せずただ200 OKを返してた。
実際、アカウントもサーバーも認識されたので、followers.jsonはなくても問題はない。
だけど。
「各 actor は followers コレクションを 持つべきである(SHOULD)」
W3C Recommendation
ということなので、それなりのものを用意しておいた方が良さそう。
mastodonもmisskey系もサーバーで共有のinbox、ビル入口の郵便受けみたいなものを持っていてそこに届いた郵便物をビル内の各人に配送するための仕組みとして、こちらのfollowers.jsonを利用してることが多いらしい。
つまり、followers.jsonを用意しておけば、こちらは相手の入ってるビル宛にだけ投稿をPOSTすればOKということ。
ビルの宛先はフォロワー情報の中のshared_inboxのURL。
ここに送ると受け取ったサーバーは、こちらのフォロワー情報をみて、サーバー内のフォロワーのinboxに配達してくれることになっている。
今のところ、これについてはActivityPubの決まり事ではない。mastodonやmisskeyなどは対応してくれているらしいが、サーバーごとで対応が違うので現物合わせ、というか確認しながら試しながら、実装するのがよさそう。
followersの記述はシンプル。
{"@context": "https://www.w3.org/ns/activitystreams",
"id" : "https://example.com/actor/followers",
"type": "OrderedCollection",
"totalItems": 5,
"orderedItems": ["https://hoo.jp/users/foo",〜]}
id,type(OrderdCollectionがMUST)、フォロワーの数、フォロワーのURLの配列。
これだけなので、フォロワーが1000人10000人もいるならともかく、10人程度なら手作業で作って用意してもいいんじゃないかな。
以上の元ネタは
https://fedibird.com/ の管理人、のえるさん(@noellabo@fedibird.com)情報
送る相手の情報に、inboxの他にshared_inboxがある場合があります。というか、MastodonやMisskeyなら必ずあります。
同じサーバに所属するフォロワーは同じshared_inboxになるので、これでまとめると、一つのサーバに対しては一回だけPOSTすれば良くなります。これによりかなり効率化されています。
宛先自体は、ToないしCcに送り元アカウントのfollowersコレクションを指定すれば、受け取ったサーバ側でそれぞれに配送してくれます。
shared_inboxがない場合は、それぞれのinboxにPOSTします。
いずれの場合も、同じIDのCreate、同じIDのNoteであれば、受け側が重複排除するので、何度も送っても大丈夫です。
フォロワーの数だけリクエストを飛ばす=マルチポストの迷惑サイトになったらまずいなあ、と心配だったのでfedibirdでそのことを投稿したら返事をいただけたもの。ありがとうございました!
AIに郵便配達を描いてもらったんだけど、なかなかうまく意図が伝わらず、かろうじてこの1枚。
» ローカル環境で電子書籍を作る、Macアプリ・Windows版ツール 「かんたんEPUB3作成easy_epub」