GTM(Google Tag Manager)とGA4の設定・開発・連携・解析

※初見1日目の感想を多分に含みます
※完全にチュートリアルです
※以下の続きです

soluna-eureka.hatenablog.com

概要

従来は単純にWebページの遷移を基準にして観測するだけでも良かったが,フレームワークが提供するイベント駆動が主体となるシングルページアプリケーションを相手に解析する上では力不足であり,しかもプロトコルの発展やスマホの普及やアプリの開発に伴って後者が主流となり始めた.そういう世相に対応すべくしてGA4が2020年後期に新たにリリースされ,今後はサービスを受けるクライアントのブラウザでイベントの発火を観測して解析する時代になると考えられる(GTMのコンテナの設定によってはサーバサイドで観測できる,ブラウザのスクリプトが省けるのでメンテナンスしやすく動作も軽く安全性も高い).
そして「用途ごとにタグをバラバラに設定する」「タグごとに処理をバラバラに定義する」「1つのサービスにバラバラにコードを埋め込む」より,「1つのコードを埋め込む」「そこから用途に応じて柔軟にタグを生成する」「観測をまとめて設定する」「そのまま解析する」方が,開発やメンテナンスの効率を高めることができるだろう,そしてそのためにあるのがGTMである.

簡潔に要約すれば「たかが観測の事情に合わせるためだけに,手動でHTMLやフレームワークを弄るのはやめよう,埋め込んだポイント1つから全て管理して,さらにイベント駆動に合わせて発火させた方が,より現代的で良い」という感じになりそう…?

やる前に

念のために前回と同様CSP設定を解除しておこう

導入

アカウント作成

  • ここに行く
  • 「新しいアカウントの追加」を選択(アカウントがないなら)
    • 権限や管理は大体GA4と同じ雰囲気がある
  • アカウント名を設定
    • GA4のアカウント名と同じようなものか
    • ⚠︎GTMアカウントとGA4アカウントとGmailアカウントは別物です!⚠︎
  • 質問には適当に回答
    • 自分しか見ないならデータ共有の類は全て却下して良さげ
    • 後からでも設定できるので問題なし

コンテナ作成

  • コンテナ名は識別しやすくサービス名を表現する
    • 「HatenaBlog」にした
  • 場所は「Web」を選択

トリガーを設定

クライアントのブラウザの挙動に紐づけたトリガを設定する,その挙動はブラウザ上のGTMのスクリプトが監視している.
今回は最も簡単な「全てに反応する」タイプを選択するが,実際は動作を限定してトリガに設定することもできる(クリック,スクロール,フォーム送信,要素の表示,DOM Ready…)し,観測したいものを限定するためにも設定するべきだと思われる.

  • 「トリガー」「新規」
  • トリガ名を設定
    • 機能や対応するイベントを簡潔に表現する
    • 「ChoHoChoDo_trigger」にした
  • 「ページビュー」を選択
  • 「一部のページビュー」から「Page Hostname」「等しい」でホスト名を指定できる
    • 「soluna-eureka.hatenablog.com」にした

タグを設定

トリガの要求に基づきタグを配信する,その中身はGA4で既に設定したものにする,これによりタグが動的に配信される.
1つのサービスにつき1つのGTMコンテナと対応するGTMスクリプトを与えることで,GA4の都合に柔軟に対応できる.

  • 「タグ」「新規」
  • タグ名を設定
    • GA4のデータストリームの名前に合わせた方が分かりやすそう
    • 「ChoHoChoDo」にした
  • 種類は「Googleアナリティクス:GA4設定」
    • このタグタイプは色々あり,Google謹製に加えてコミュニティ提供もある
    • その気になれば「カスタムHTML(script埋め込み可)」で自作できるとか
  • 「測定ID」には事前にGA4で得た測定IDをコピペ
  • 設定フィールドに「debug_mode:true」を入れるとGA4のデバッグモードを使えるようになる
    • これはGTMのデバッグモードとは別物だが,GTMで配信するタグのプロパティを変えることでGA4に影響を与えられる
  • 詳細設定の呼び出しオプションを「無制限」に設定
  • トリガーには先に設定したトリガを変数として指定

ワークスペース,プレビュー,マージ,デプロイ

GTM上で作業を進めるワークスペースは1つのコンテナでいくつか(無料個人アカウントは3つまで)の運用が可能で,複数人で別々のワークスペースごとに作業をした後は,gitのように代表ワークスペースへマージが行える.コンフリクト?競合?衝突?が発生したら手動で直すらしい,その際にはちゃんと通知が出るそうなので,皆んなで協力して完全なマージを目指そう.
またワークスペースごとにプレビューが行える,通常のデバッグページとは異なりプレビュー用の環境が用意されるので,タグが想定通りの動作をしているかどうかを確認しよう(URLをコピペするだけで共有ので,異なる環境でテストを実行できる).

ちなみにChrome限定の拡張機能であるTag Assistant Companion からは通常のデバッグページが開くけど重いので,少しだけ見たいような時はTag Assistant Legacyで十分だと思う,こちらもエラーメッセージなどはちゃんと表示されるので大丈夫.

そうした作業が終わり次第でコンテナをデプロイ(公開)することができるようになる,これを経てようやくGTMがアクセスを受け入れ始める.ワークスペースの公開を押すかバージョンから選択して公開するかでOK,後はもうサービスに埋め込むだけ…

GA4のデバッグ

こちらはChrome限定の拡張機能であるGoogle Analytics Debuggerを入れた上で対象のページを開きつつGA4のページの「設定」→「Debug View」を押すと利用できる,GTMが動作したらGA4も動作しているかどうか確認しよう.

利用

「管理」→「Googleタグマネージャーをインストール」に移動,指示に従いなるべく規定の場所にそのコードをコピペする
はてブロの場合「設定」→「詳細設定」→「Googleタグマネージャ」にコンテナのIDを貼り付けよう,GA4のIDではない.

解析

GA4で行う,データが手元に出揃い次第で追記する,とりあえずアクセス分類系のグラフ作成はやりたいが1週間は欲しい

放置してたら5日分のデータが集まったのでやってみる
※最低でも2日分以上のデータがないと正常に動作しないっぽい

レポートのスナップショット

  1. 右上のカレンダーから日付を選択
  2. 自由選択が可能
  3. 右上のパッドから項目を編集
  4. 「平均エンゲージメント時間、他 2 個」
    • 「滞在した時の平均滞在時間」「ユーザあたり滞在ページ数」「全ての場合の平均滞在時間」

    • カラムの横幅が足りずに表示を切り替えられない場合がある

  5. 「リアルタイム」
    • 直近30分限定
  6. 「表示回数(ページタイトルとスクリーンクラス)」
    • ページ名が表示される
  7. 「セッション(市区町村)」
    • アクセス元が表示される

f:id:Soluna_Eureka:20211225104935p:plain

「ユーザー」とは?

Cookieかなんかを使ってユーザ数を判定していると思われる(詳細不明),つまり1Cookieにつき1人っぽい

support.google.com

「セッション」とは?

サイトに来てから出る(≡30分経過)までを1セッションとする,つまりユーザ数<セッション数になり得る
↓これはUAの記事だけど大体は同じっぽい

support.google.com

実際は30分から変更することもできるらしい,今回は変更していない

support.google.com

「エンゲージメント」とは?

詳細は不明だが通常はページを読んだと判定されたら1エンゲージメントとなる,GA4の判定機能はそこそこ高性能らしい
ユーザ数<エンゲージメント数もあり得るし,セッション数<エンゲージメント数もあり得る

support.google.com

「イベント」とは?

ユーザが何かする度にイベントは発生する,GA4は端末上でそれを観測してサーバに送信する,それを集計して得られるもの

support.google.com

基本的な(デフォルトで使える)(必ず観測される)イベントの一覧

support.google.com

データストリームの設定を少し弄るだけで追加で使えるイベント一覧

support.google.com

カスタムイベントも実装できるらしいが今回は扱わない,むしろ自力で実装してまで使った方が良い場面がどれほどあるのか…

「表示回数」vs「閲覧開始数」

前者はイベント「page_view(Web向け)」「screen_view(App向け)」が発生した数の合計(ページの読み込みor閲覧履歴の更新の度に発火する),後者は任意のページがセッションのエントリーポイントとなった回数(つまり遷移した先はノーカン)

support.google.com

ちょっと前者の発生条件や計測方法がイマイチよくわからない,ちなみに「scroll(9割スクロールしたら1回だけカウント)」も含めて拡張計測機能を設定しないと収集してくれない,せっかくなので試してみよう

f:id:Soluna_Eureka:20211225104600p:plain

リアルタイム

直近30分の動向を全て確認できる,表示内容はカスタマイズできない
※地図上では普通に市町村のレベルで表示されるので,個人情報を扱うサービスを運営している時は気をつけよう

f:id:Soluna_Eureka:20211225105401p:plain

探索

  • なんでも良いのでとりあえず「空白」から作成してみよう
    • エクセルみたいに1つのワークシートで複数のページを使えるから問題ない
    • 用意されているテンプレートもページごとに使い分けられるから問題ない

ソース別アクセス数

実ははてブロを3つに分けて使っているので,それぞれに個別のデータストリームをGA4で用意して,それに対応するトリガとタグをGTMで用意した,そのおかげでデータストリームで分類すればブログごとの数字が得られるということになる
※実際のところストリームの複数設置はあまり推奨されないらしい

手法は「自由形式」を選択,ビジュアリゼーションを「折れ線グラフ」に設定,指標に「表示回数」を呼び出して値に代入,ディメンションに「ストリーム名」を呼び出して内訳に代入

f:id:Soluna_Eureka:20211225110229p:plain

地域別アクセス数

手法は「自由形式」を選択,ビジュアリゼーションを「ドーナツグラフ」に設定,指標に「表示回数」を呼び出して値に代入,ディメンションに「地域」を呼び出して内訳に代入

f:id:Soluna_Eureka:20211225110735p:plain

ちなみに東京都と千葉県だけでほぼ半分を占めていたし神奈川県を含めれば3分の2に届きそうだった,地域格差…ってコト!?

ソース別エンゲージメント率

手法は「自由形式」を選択,ビジュアリゼーションを「折れ線グラフ」に設定,指標に「エンゲージメント率」を呼び出して値に代入,ディメンションに「ストリーム名」を呼び出して内訳に代入

f:id:Soluna_Eureka:20211225111102p:plain

ちなみにエンゲージメント率とは「エンゲージメントがあったセッション数/セッション数」で定義されている

ページ遷移割合

テンプレート「経路データ探索」を用いるとページごとのアクセスの経路や割合が見えてくる,イベントごとに追跡しているがぶっちゃけページ名だけ表示させることも可能
各テンプレートにおいては解析する内容がほぼ決まっているので,使える変数が向こうから指定されることが多い

手法は「経路データ探索」を選択,指標に「イベント数」を呼び出して値に代入

f:id:Soluna_Eureka:20211225111814p:plain

「session_start」からいきなり「scroll」の飛んでいるのが謎,タイムアウト後にスクロールしつつ画像を読み込んだとか…?

スクロール率

セグメントを使い分類を可視化するのだが,なぜか2日分以上のデータじゃないtpセグメントが生成されないので注意

手法は「セグメントの重複」を選択,指標に「表示回数」を呼び出して値に代入(加えて「利用ユーザー」がデフォルトで設定される),ディメンションに「ページ タイトルとスクリーン名」を呼び出して内訳に代入
およびセグメント「目に入った」「読み進めた」をそれぞれ作成してセグメントの比較に代入

f:id:Soluna_Eureka:20211225112754p:plain

f:id:Soluna_Eureka:20211225112806p:plain

f:id:Soluna_Eureka:20211225112856p:plain

セグメントの重複を図で表現できる基準の値が完全に固定でユーザ数に限られているので完全に自由には扱えない,が利用目的がよくあるヤツならユーザがどう動くかを解析して重複を探るパターンがほとんどだろう,ちなみにセグメントはまだ増やせる

スクロールの有無とエンゲージ時間

ページを見た時間やセッションが継続した時間を表示する,データストリームごとの分類には対応していない

手法は「ユーザーのライフタイム」を選択,指標に「ユーザーの合計数」「全期間のエンゲージ期間(平均)」「全期間のセッション時間(平均)」を呼び出して値に代入,セグメントに「目に入った」「読み進めた」をそれぞれ作成してセグメントの比較に代入,およびピボットを「最初の行」に設定

f:id:Soluna_Eureka:20211225114811p:plain

読み進めるユーザーは5分ほどページを表に表示して,なおかつセッションが切れる(デフォルトの30分が経過する)まで裏にページが残り続けるらしい,そうでない場合は2分ほど表示して10分ほどでセッションが切れる…みんなタブ閉じないっぽいね…

余談

ブラウザの設定やプラグイン(アドオン)によっては,ブラウザ上でGA4がそもそも動作しないケースを確認している,Cookie殺し・キャッシュ殺し・セッション殺し・トラッカー殺しの流行はもはや確実であり,というかはてなブログの閲覧数と明らかに合ってないので,このデータ収集に引っかかった人はコンテンツブロッカを搭載していないスマホのブラウザからのアクセス ではないかと睨んでいる.
そう考えるとやはりサーバーサイドで実装するのが最善かと思われるが,市販サービスに乗合で利用するにはページに埋め込むタイプのものしか(ほぼ)使えないわけで,まぁ無料でこれだけ遊べて興味深い結果が得られるというのなら妥協しよう…

しかしアメリカからのアクセスが本当に謎で,メリーランド州のヘイガーズタウンという場所からのセッションが2回も観測されている.1回ならまだわかるが2回はちょっとわからない,英語のブログなんか書いたことないし縁もゆかりもないはず…

Google Analytics(GA4)の設定とContent Security Policy(CSP)

ページのソースコードにありがちなのコレ(今ではGAよりもGA4が一般的)らしい

<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=<measurment ID(String)>"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  gtag('config', <measurment ID(String)>);
</script>

クライアントブラウザが測定ID付きURLを読み込んでjsを叩くと自動でGoogleに送信される

やる前に

GA4のページに仕込まれたインラインスクリプトChromeのCSP設定で弾かれるので(自社製品同士なんだし上手くデバッグしてくれ)

  • 「環境設定」
  • 「プライバシーとセキュリティ」
  • 「安全でないコンテンツ」(1番下にあるはず)
  • [*.]analytics.google.comをurlに追加

しておこう,CSPが設定されたページでは危険な関数(eval系など)や許可のないurlから得たjsをブラウザが実行せず,前者のせいで操作不能になる
ちなみに実際にGA4を埋め込むと後者が原因で動かない時があるらしい,はてなブログはMathJaxが動くから流石にセーフだと思うが,自分でCSPを設定できるサイト(<meta>タグの中かhttpヘッダの中に入れるらしい)の場合はGA4に対応するurlを許可しないといけない(ここでは扱わない)

導入

アカウント作成

  • ここに行く
  • 「無料で設定」を選択(アカウントないなら)
  • アカウント名を設定
    • とりあえず自分の名前にした
    • ⚠︎GA4アカウントとGmailアカウントは別物です!⚠︎
    • 管理者はちゃんと管理しておこう(激うまギャグ)
    • 複数のアカウントが持てるらしいが今は必要性を感じてない
  • 質問には適当に回答
    • 自分しか見ないならデータ共有の類は全て却下して良さげ
    • 後からでも設定できるので問題なし

プロパティ作成

  • プロパティ名は識別しやすくサービス名を表現する
    • 「HatenaBlog」にした
  • タイムゾーンと通貨は日本で設定
    • 本当は場所に合わせた方が良い
  • 「ユニバーサル アナリティクス(GA)のプロパティのみを作成する」は今回は却下
    • ここ曰くはてブロではデフォでGAを埋め込めるらしい
    • プロパティは後から作れるので何かあればやり直せば良い
    • GA(昔からある)とGA4(まだ1年も経ってないらしい)では機能が違うらしい,まぁどうせ改良されるだろう

ストリーム作成

  • 「管理」→「データストリーム」→「データストリームを追加」→「Web」
    • スマホアプリ埋め込み型もあるらしい
  • ストリームURLに対象ドメインを設定
    • https://blog.hatena.ne.jp/Soluna_Eureka/soluna-eureka.hatenablog.comにした
  • ストリーム名に識別しやすくページ名を設定
    • 「ChoHoChoDo」(調法調度)にした
  • 拡張機能を全てオンにする
  • すると測定IDが生成される

利用

「タグ設定手順」の上のやつを開いてコピーしたら任意のページの<head></head>にコピペ
はてブロなら「デザイン」「🔧」「ヘッダ」に入れれば動くはず(GAとGA4ではID形式が違うので直埋め必須)

Google Tag Manager(GTM)

これを使うと実装や管理や楽になるらしいが,今回は扱わない(長くなりそう)

手元でどう映るか

俺はお前が俺を見たのを見たぞ

f:id:Soluna_Eureka:20211219003434p:plain
test

これがやりたかっただけ ちなみに解析は丸1日経たないとできないらしい

最新のhtml+javascript+cssにおける「真のカスタムエレメント」を考える

やりたかったこと

ここ最近,vscode上でmarkdownをひたすら使い潰すためだけに色々とやってきているのだが(以下はこれまでの記事),

soluna-eureka.hatenablog.com

soluna-eureka.hatenablog.com

soluna-eureka.hatenablog.com

これまでの研究によりmarkdown pdfextension.jsを書き換えることで任意の.html.js.cssファイルをプラグインとして利用できる環境を作れてしまうことが判明し,これにkatexを混ぜることで概ね万能な数式の表示すら可能となった.

しかし大元の発端は「latexが面倒だからmarkdownを使おう」というものであり,つまるところlatexにある機能がkatexさらにはそれが対応できるmarkdownには搭載し得ないもので,これは下位互換にすぎない不完全な環境であったのだ.
そこで私が考えついたのが,「latexに備わる組版用の各種コマンドをhtmlの機能で代替できないか?」というものである.

目標

完全に独立した名前を持ち,余計な指定をする必要がなく,かつidstyleが自在に設定できる「真のカスタムエレメント」を実現する.これによりlatexコマンドに対応するカスタムエレメントを自由に作成,柔軟な組版markdownで実現する.

方針

かつてはdocument.registerElementで好き放題にできていたらしく,その手法を扱った記事がいまだに検索にヒットするが,

developer.mozilla.org

現在では全ブラウザ非対応の憂き目に合っている(リンク先は英語ページのみ).ゆえに代替であるCustomElementRegistry

developer.mozilla.org

インターフェースが持つcustomElements.define(あっちはElementなのにこっちはElementsなの,控えめに言って最悪)

developer.mozilla.org

を使用せざるを得ないが,現在では数多くの記事においてこのページが示す「自律型」「拡張型」の2種類のみを扱っている.

しかし前者はタグの間の文字を扱えない(DOMパースがシカトされる)・後者はis=''の指定が必要(DOMパースしてくれる)ため,私の要求には満たない.他方でwhatwghtml系開発コミュニティ)によるcustom elementsのリファレンスでは,

triple-underscore.github.io

カスタム要素定義を登録するのは、 それに関連する要素が初期時に — 構文解析器などにより — 作成された後の方が,好ましいこともある。 昇格は、 そのような用法を可能化して,[ カスタム要素の内容を漸進的に増強する ]ことを許容する。

という内容もあり,つまりは「名前に-が入っているエレメントはカスタム要素として解析が後回しにされる」程度には動作の許容がある.更には「任意のhtml要素を指す」HTMLElement(任意?あっ…)が現在でも全てのブラウザで実装されている

developer.mozilla.org

ため,この2つを足がかりにして攻略していった結果,なぜだか無事に攻略に成功した.

困難

カスタムエレメントなだけあって処理順の設定が難しく,上記2点の仕様が変わらなくても他の関数の仕様が変わるだけで死ぬ可能性は否定できない.これはあくまで2021年12月現在の情報であることはお許しいただきたい(未来のことは知らない).

やり方

まずは上の3つの記事を読むことこそお勧めする(プラグイン管理が格段に楽になるので)が,実際は読まなくても大丈夫だ.

原理

以下では<div></div>由来のボックスとしての<d-box></d-box>を作成する.

class DBox2 extends HTMLDivElement {
    constructor() {
        super();
        this.id = 'box';
    }
}

customElements.define('d-box2', DBox2, { extends: 'div' });


class DBox extends HTMLElement {
    constructor() {
        super();
        let wrapper = document.createElement('div');
        wrapper.id = 'box';
        let content = this.innerHTML;
        console.log(content);
        this.innerHTML = '';
        wrapper.insertAdjacentHTML("afterbegin", content);
        this.insertAdjacentElement("beforeend", wrapper);
    }
}

document.addEventListener("DOMContentLoaded", function() {
    customElements.define('d-box', DBox);
});
body {
    color: #222;
    background-color: #fff;
}

#box {
    color: #444;
    border: 5px solid #666;
    border-radius: 10px;
    padding: 5px;
    margin: 5px;
}
# これはなんですの?

## `custom element`ですわ!

マニュアル通りの手法では
<div is='d-box2'>
sasa
</div>

わたくしが極めた手法では
<d-box>
二重動作もOKですわ!
<d-box>
$$
    \begin{align}
    e=mc^2 \\\\
    e^{i\pi}+1=0
    \end{align}
$$
</d-box>
</d-box>

f:id:Soluna_Eureka:20211210032740p:plain

何をやったか

なんの情報も持たない<d-box></d-box>の中身を丸ごと文字列で抜き取り<div id='box'></div>を埋め込んで更にそこに元の文字列を入れただけ,要するに作業としては大したことはやっていない…が,処理同期手法の試行錯誤が面倒だったことに加え,そもそも「そういうやり方しかない」という結論に至るまでに半日ほど要してしまったことがあり,少し辛かった.

ちなみに<div is='d-box2'></div>はよく紹介されているやり方である,どちらも同じ結果を得ている以上これは成功だろう.

クラス化

superのリファレンスに目を通した時に「この処理はチェーン化できるのでは」と気づいたので,実際に実装に成功してみた.

developer.mozilla.org

結果は省略するが以下にクラス処理に関する記述を載せる,例としてAncestorOfAllからフォールバックし3段組を実現する.

class AncestorOfAll extends HTMLElement {
    constructor(element, purpose) {
        super();
        let wrapper = document.createElement(element);
        wrapper.id = purpose;
        let content = this.innerHTML;
        console.log(content);
        this.innerHTML = '';
        wrapper.insertAdjacentHTML("afterbegin", content);
        this.insertAdjacentElement("beforeend", wrapper);
    }
}

class DCol3 extends AncestorOfAll {
    constructor() {
        super('div', 'col3');
    }
}

document.addEventListener("DOMContentLoaded", function() {
    customElements.define('d-col3', DCol3);
});
#col3 {
    column-count: 3;
    padding: 20px;
    margin: 10px;
}

要はsuper()super()を呼び出しているに過ぎず,あとは先程と同じように<d-col3></d-col3>で呼び出せば良いだろう.

立つ鳥跡を濁さず

カスタムエレメントを使用した痕跡を全く残さないということも可能ではあり(自分でデバッグできなくなるが),これを活用するとkatexの環境や関数をelement風に呼び出せるため非常に可読性が高まってとても良い.ただし環境と関数の定義の順番を間違えると動作しなくなったり,一部の無駄だと思える要素を省略すると動作しなくなったりするため,注意が必要である.

// define your custom math func
// これを後にすると動かない
class AncestorOfMathFunc extends HTMLElement {
    constructor(purpose) {
        super();
        let mathHead = '\\begin{' + purpose + '}';
        let mathTale = '\\end{' + purpose + '}';
        this.insertAdjacentHTML('afterbegin', mathHead);
        this.insertAdjacentHTML('beforeend', mathTale);
        let content = this.innerHTML;
        this.insertAdjacentHTML('beforebegin', content)
        this.remove();
    }
}

class PMatrix extends AncestorOfMathFunc { //丸括弧の行列表記
    constructor() {
        super('pmatrix');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('g-pmatrix', PMatrix); //<g-pmatrix></g-pmatrix>で呼び出し
});

class Cases extends AncestorOfMathFunc { //場合分け表記
    constructor() {
        super('cases');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('g-cases', Cases); //<g-cases></g-cases>で呼び出し
});


// define your custom math mode
// これを先にすると動かない
class AncestorOfMathMode extends HTMLElement {
    constructor(element, purpose) {
        super();
        let breaking = document.createElement('br');
        let mathHead = '\\begin{' + purpose + '}';
        let mathTale = '\\end{' + purpose + '}';
        let mathWrapper = '$$';
        this.insertAdjacentHTML('afterbegin', mathHead);
        this.insertAdjacentHTML('afterbegin', mathWrapper);
        this.insertAdjacentHTML('beforeend', mathTale);
        this.insertAdjacentHTML('beforeend', mathWrapper);
        this.insertAdjacentElement('beforeend', breaking); //これを無効化すると動かない
        let wrapper = document.createElement(element);
        wrapper.id = purpose;
        let content = this.innerHTML;
        this.innerHTML = '';
        wrapper.insertAdjacentHTML('afterbegin', content);
        this.insertAdjacentElement('beforebegin', wrapper);
        this.remove();
    }
}

class DAlign extends AncestorOfMathMode { //align環境
    constructor() {
        super('div', 'align');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('d-align', DAlign); //<d-align></d-align>で呼び出し
});

class DAlignN extends AncestorOfMathMode { //align*環境
    constructor() {
        super('div', 'align*');
    }
}
document.addEventListener('DOMContentLoaded', function() {
    customElements.define('d-alignn', DAlignN); //<d-alignN></d-alignN>で呼び出し
});

この環境定義の動作手順は

  1. タグの中身の文字列を操作
  2. 数式環境の呼び出しに対応
  3. <div></div>を生成
  4. idを設置し書式設定が可能な状態に
  5. タグの中身をinnerHTMLと解釈
  6. 生成した<div></div>にコピペ
  7. 痕跡を消去

という流れで,関数定義の動作手順とは<div></div>を生成するかどうかに違いがある.
かつdocument.addEventListener('DOMContentLoaded', function() {}は,その定義順に実行される.
そこで何かしらkatexの解釈に不都合が事象が発生している…と考えられるだろう.

欠点

本文や数式の分量増えるほどに動作が遅くなる(DOMを操作する量が多くなるから),markdownプレビューがkatexを含め機能しなくなる(vscode上の機能が解釈できないから)…等がある.正直katexレンダリング速度に割と助けられている.

感想

恐らく初めてjavascriptのクラスのお勉強をしたし,処理の同期や非同期について考えた.template.js<head><\head>に入っているし,恐らくどこに置いても正常に動作すると考えられる(一部のリファレンスは末尾設置を推奨しているけど). ただし念のためにkatexよりも前に読み込む設定にはした方が良い,レンダリングのどこかで不都合が起きるかもしれない.

今後よりlatexライクな環境に近づけようなどと考えているが,もしこれに使い道があれば是非とも使ってもらえると嬉しい.

TeX + vscode + git on WSL2(Windows Subsystem for Linux 2) 設定メモ

なぜWSL2?

Windows10にTeXを直乗せして動かしたくせない理由として,vscode拡張機能であるlatex workshopからtexに備わる一括コンパイル機能であるlatexmkを最優先に使用したいのに,その手法がwindowsと致命的に相性が悪いことが挙げられます.
ご存知の通りにwindowsのパスの区切りはバックスラッシュ(円記号)ですが,macOSLinuxと同じ設定のままlatex workshopからlatexmkを起爆すると,なぜかtex関係ファイルの絶対パスがスラッシュ/とバックスラッシュ\が入り混ざったものとして生成されてしまい,ただちにundefined control sequenceI can't find fileコでンパイルエラーを吐かれてしまいます.
どこに原因があるか詳しくはわかりませんが,vscodeで開いているルートフォルダの直前にだけ\が入っているならまだしも,それ以外の場所にも\が入っていたりするために,個人的にはこの問題の解決のためだけに労力を割くのは面倒だと思いました.

現状ではUNIXLinuxのパス表記が圧倒的に正義である(とても強い主張)以上は,OSも拡張機能もデフォルトでパス表記に/を使う認識が共有されているmacOSもしくはLinuxを常に使用することこそ非常に正しい選択肢であり,マシンリソースが十分にあるならばWSL2の採用を検討すべきでしょう.マルチプラットフォームな作業を目指すなら,尚更にPowerShellを含めたWindowsのデフォルト環境を直ちに捨てることこそが,余計な労力を減らすことに繋がります.ここではそのやり方についてメモをしていきたいと思います.

PowerShellの導入

これがないと話になりません,絶対に必要なので速やかに導入しましょう.

docs.microsoft.com

WSL2の導入

頻繁に更新されているらしいので,バージョン違いは気をつけましょう.これは2021年12月における最新の情報のはずです.

OSアップグレードと導入

現行最新版のWindows10 21H2ならWSL2が簡単に利用できます.WSLと比べて処理性能が格段に向上しているため,直ちにアップグレードして簡単なWSL2の導入環境を整えましょう.幾らか古い版でも導入こそできますが,マニュアルを見つつ行う手動でのインストールは非常にダルい上に下らないミスを誘発しかねませんので,やめた方がお得です.
Window11ならデフォルトでOKです,その後は以下の手順に従います.

docs.microsoft.com

docs.microsoft.com

種類は主にUbuntuDebian・KaliLinuxですが,特に需要がなければ素直にUbuntuで良いでしょう.

お互いのファイルを参照するには

例えばWindowsからWSLもしくはWSLからWindowsでファイルを交換したい場合,Windowsから見て

\\wsl$\<環境の名前>

がWSLのルートディレクトリであり(ネットワークドライブ),その逆にLinuxから見て

/mnt/c

がcドライブになります(他のドライブも同様)(マウントドライブ).

環境(ディストリビューション)の複製

WSL2では動作環境をtarしながらexportもしくはimportすることができます.基本的にパッケージ扱いであるため,C:\Users\<ユーザーの名前>\AppData\Local\Packagesの中に紛れ込んでいるのですが,これを行えばバージョン管理や環境保全ができます.そのためには以下を参考にします.

docs.microsoft.com

docs.microsoft.com

前者ではDockerのimageからLinuxを叩き起こしそれを吸い取るやり方が載ってますが,Windowsから供給されるバージョンでよければ無視して良いです.Dockerの導入は以下を参照してください(必ずwsl2を設定し終わってからやりましょう,すると\\wsl$\にDocker系が入ってきてくれます).

docs.microsoft.com

この記事においては本質ではないのでパスします.より難しいことは以下を読みましょう.

docs.microsoft.com

ディストリビューションの実体

デフォルトでは

C:\Users\<ユーザーの名前>\AppData\Local\Packages\CanonicalGroupLimited.UbuntuWindows_<謎のハッシュ値>\LocalState\ext4.vhdx
C:\Users\<ユーザーの名前>\AppData\Local\Packages\KaliLinux.<謎のハッシュ値>\LocalState\ext4.vhdx
C:\Users\<ユーザーの名前>\AppData\Local\Packages\TheDebianProject.DebianGNULinux_<謎のハッシュ値>\LocalState\ext4.vhdx

にありますが,前述したexportおよびimportを行い丸ごとdドライブに移行することも可能です.

zshの導入

Ubuntuを適切に導入した場合のシェルはおそらくbashですが,より新しく多機能なzshをお勧めします.そのためにはまずsudo aptUbuntuをアップデートしてからzshを入れます.

sudo apt update
sudo apt upgrade
sudo apt install zsh
sudo chsh -s $(which zsh)

必要な依存パッケージがインストールされるかと思いますが,上手くいけば

echo $SHELL #->zsh?

が得られます.この後で~/.zprofile~/.zshrcを作成しても構いません.

それぞれの役割

前者はログインシェルの起動時に読み込まれ,後者はそれに加えてインタラクティブシェルの起動時に読み込まれます.これは後述しますが,パス設定は後者に行う方が良いでしょう.

texをダウンロード

まずはmacOSに倣って~/Downloadsを作成しましょう.そこに移動したら次に

curl -O http://ftp.jaist.ac.jp/pub/CTAN/systems/texlive/tlnet/install-tl-unx.tar.gz

で一式を落とします.

余力があればで良いですが,こういたデフォルトのツールも古めだと思われるので,macOSに倣ってhomebrewを導入し完全なパッケージの管理体制を整えましょう.

docs.brew.sh

sudo apt-get install build-essential procps curl file git
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

texのインストール

先ほどのファイルを

tar -xvf install-tl-unx.tar.gz

で解凍し,install-tlがある位置に移動します.次に

sudo ./install-tl --repository http://ftp.jaist.ac.jp/pub/CTAN/systems/texlive/tlnet

でインストールを実行します.
この際にインストールオプションを聞かれますが,とりあえずはフルセットで導入しても構いません.またインストールには時間がそこそこかかります,もし失敗した際は設定したオプションを使い回す

--profile installation.profile

をコマンドに追加して繰り返し挑戦してみましょう.

GUIは必要か

wslでguiが使えるwslgなるプラグインこそあるそうですが,windows10の21H2のwsl2ですら現状では使えていません(windows11のデベロッパ向けのinsider previewでのみ使えるようです).

github.com

tex組版においてguiを使う場面があるとすれば生成したpdfを見る時くらいですが,それは前述した方法でファイルにアクセスすれば良いので,ぶっちゃけコマンドさえあればOKです.

パスを通す

現状ではzshにパスが通っていない上にmacOSのように便利なシンボリックリンクも作られていませんので,vscode拡張機能は実行ファイルを見つけられません.そのためmacOSに倣って以下の手順でパスを通します.デフォルトでは\usr\local\texlive\2021\bin\x86_64-linuxにあります.

シンボリックリンクの作成

まず/Library/TeX/binを作成し(sudoが必要です),そこに/usr/local/texlive/[]/bin/x86_64-linuxをリンクします.これはわかりやすいようにディレクトリ名を揃えていますが,お好みで構いません.

sudo ln -s /usr/local/texlive/<バージョン年度>/bin/x86_64-linux /Library/TeX/bin/x86_64-linux

もしバージョンが変わった場合は適宜に年度を変更してリンクし直しましょう,ちなみにmac texではデフォルトでこの作業をやってくれます(なのでhomebrewとの食い合わせがよろしくないですね).

パスを作成する

~/.zshrcの最後にこの場所を書き加えます,これよりも上位の設定ファイルは弄らない方が吉です,手動でやらない場合でも以下を行えば結構です.

echo 'export PATH="/Library/TeX/bin/x86_64-linux":$PATH' >> ~/.zshrc

念の為にwhich latexmkで見つかるかどうかを確認しておきましょう.

vscodeを導入そして設定

microsoft謹製のvscodeならデフォルトでwsl2の中を扱えます,以下を参考に導入しましょう.

docs.microsoft.com

この時点でubuntuが立ち上がり次第で自由にファイルの編集が行えるだろうと思われます.

gitとlatexmkを設定

ここから先はwsl2に限らない汎用な設定になりますので,以下を参照してください.

soluna-eureka.hatenablog.com

soluna-eureka.hatenablog.com

私の環境ではmacOSに倣った設定を流用して特に問題はありませんでした,すなわちvscodeの設定ファイルを同期させても問題が起きないため,wsl2を使用する恩恵が受けられるというものです.

感想

まさか私がwin・maclinux・wslの四刀流をするとは思いませんでした…

globalにgitでignoreをsetする手順

やり方

git環境は整っていることが前提

ファイルを作る

touch $HOME/.config/git/ignore

ファイルに書き込む

.DS_Store

拡張子指定で一括

*.code-workspace

試してみる

https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreexcludesFile
https://git-scm.com/docs/gitignore#_description
が正しければ,これだけでパスは通っているはずである,試しに動かそう

...
 Defaults to $XDG_CONFIG_HOME/git/ignore.
If $XDG_CONFIG_HOME is either not set or empty,
$HOME/.config/git/ignore is used instead.
See gitignore[5].
...

上手くいけばOK,ダメなら次の手順へ

パスを通す

git config --global core.excludesfile $HOME/.config/git/ignore

これをやると

[core]
    excludesfile = /Users/SolunaEureka/.config/git/ignore

でパスが通るなぜわざわざこんなことやらにゃならんのだ
流石にコレなら動くだろう

パスを消す

git config --global --unset core.excludesfile      

理由は知らんけど消してもしばらく動く場合がある,つーか私はそうだった

おまけ

初めてgithubでレポジトリ作ったけど,作った時点で案内に乗せられてREADMEを作ると,強制的に「initコミット」が発生してしまい,既に手元に作り溜めしたファイルがある場合は,そのブランチにはプッシュできない・別ブランチにpushできてもプルリクできない,という「詰みの状況」が発生した,There isn’t anything to compare.って表示されていた,いや確かにそれはそうなのだが…

あとパスの挙動が謎すぎた,まぁ今はちゃんと動いてるからコレでよしとするか…

markdown pdfを魔改造した

利点

vscode拡張機能のコアファイルは保存場所が既に決定されており,それが使うパスの書き換えは表からはほぼできない
なので内部のスクリプトを弄ってmarkdown pdf関係の書類をクラウドなどで共有できるようにした,これならコピペ不要
さらにどの場所でmarkdown pdfをやっても必ず同じ結果が得られるようになる,ただしvscodeの環境設定で弄れる範囲を除く

欠点

vscodeの環境設定にパラメータを追加できるのは開発者だけらしく大元ディレクトリのフルパスとプラグインのリストアップはextension.jsに書き込む必要があり面倒

概要

そこそこに改変した
まずmarkdown pdf拡張機能のコアファイルのextension.js(ここの最後の方でいじってたやつ)を以下でコピペして上書きする,だいたい1050行くらい
次にmyGroundPathとmyPluginListをその冒頭箇所に指示通りに書き込む,Windowsではエスケープ処理を忘れないように
更に関係ファイルはmyGroundPathの配下に(例として)以下のように設置する,プラグインは同名のcss・html・jsをそれぞれ必ず配置してmyPluginListとの対応を必ず確保しておく
最後にbase.htmlも間違えずにコピペする,コレで終わり

.
├── base
│   └── base.html
├── plugins
│   └── math
│       ├── math.css
│       ├── math.html
│       └── math.js
├── template
│   ├── template.css
│   ├── template.html
│   └── template.js
└── test
    ├── test.html
    └── test.md

ここのmathとはmarkdown pdf上でkatexを動かすのに必要な設定やマクロなどを集めたもので,導入スクリプトと設定スクリプトで分けてある
そっちの周りのお話はここを参考にしてほしい

ソース

以下はbase.html

<!DOCTYPE html>
<html>
<head>
<title>{{{title}}}</title>
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
{{{frame}}}
{{{style}}}
{{{script}}}
{{{mermaid}}}
<script>
    // insert your test script!
    console.log("rendered from base.html!");
</script>
</head>
<body>
  <script>
    mermaid.initialize({
      startOnLoad: true,
      theme: document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
          ? 'dark'
          : 'default'
    });
  </script>
{{{content}}}
</body>
</html>

ここの{{{example}}}などに中身を突っ込むためのテンプレートエンジンはmustache.js,と言うよりextension.jsも含め拡張機能全体がnodeで動作しているっぽい,それはそうか

以下はextension.js

'use strict';
var vscode = require('vscode');
var path = require('path');
var fs = require('fs');
var url = require('url');
var os = require('os');
const { least } = require('d3-array');
var INSTALL_CHECK = false;

// set mother directory path for entire files as String.
const myGroundPath="/hogehoge/hugahuga/markdownPdf";
// set list of extensions as List[String,String...].
// you should place files like
//
// markdownPdf
// ├ foo
// │  ├ foo.html
// │  ├ foo.js
// │  ├ foo.css   
// ├ bar
// │  ├ bar.html
// .  ...
//
//.
const myPluginList=["foo","bar"];



function activate(context) {
  init();

  var commands = [
    vscode.commands.registerCommand('extension.markdown-pdf.settings', async function () { await markdownPdf('settings'); }),
    vscode.commands.registerCommand('extension.markdown-pdf.pdf', async function () { await markdownPdf('pdf'); }),
    vscode.commands.registerCommand('extension.markdown-pdf.html', async function () { await markdownPdf('html'); }),
    vscode.commands.registerCommand('extension.markdown-pdf.png', async function () { await markdownPdf('png'); }),
    vscode.commands.registerCommand('extension.markdown-pdf.jpeg', async function () { await markdownPdf('jpeg'); }),
    vscode.commands.registerCommand('extension.markdown-pdf.all', async function () { await markdownPdf('all'); })
  ];
  commands.forEach(function (command) {
    context.subscriptions.push(command);
  });

  var isConvertOnSave = vscode.workspace.getConfiguration('markdown-pdf')['convertOnSave'];
  if (isConvertOnSave) {
    var disposable_onsave = vscode.workspace.onDidSaveTextDocument(function () { markdownPdfOnSave(); });
    context.subscriptions.push(disposable_onsave);
  }
}
exports.activate = activate;


// this method is called when your extension is deactivated
function deactivate() {
}
exports.deactivate = deactivate;

async function markdownPdf(option_type) {

  try {

    // check active window
    var editor = vscode.window.activeTextEditor;
    if (!editor) {
      vscode.window.showWarningMessage('No active Editor!');
      return;
    }

    // check markdown mode
    var mode = editor.document.languageId;
    if (mode != 'markdown') {
      vscode.window.showWarningMessage('It is not a markdown mode!');
      return;
    }

    var uri = editor.document.uri;
    var mdfilename = uri.fsPath;
    var ext = path.extname(mdfilename);
    if (!isExistsPath(mdfilename)) {
      if (editor.document.isUntitled) {
        vscode.window.showWarningMessage('Please save the file!');
        return;
      }
      vscode.window.showWarningMessage('File name does not get!');
      return;
    }

    var types_format = ['html', 'pdf', 'png', 'jpeg'];
    var filename = '';
    var types = [];
    if (types_format.indexOf(option_type) >= 0) {
      types[0] = option_type;
    } else if (option_type === 'settings') {
      var types_tmp = vscode.workspace.getConfiguration('markdown-pdf')['type'] || 'pdf';
      if (types_tmp && !Array.isArray(types_tmp)) {
          types[0] = types_tmp;
      } else {
        types = vscode.workspace.getConfiguration('markdown-pdf')['type'] || 'pdf';
      }
    } else if (option_type === 'all') {
      types = types_format;
    } else {
      showErrorMessage('markdownPdf().1 Supported formats: html, pdf, png, jpeg.');
      return;
    }

    // convert and export markdown to pdf, html, png, jpeg
    if (types && Array.isArray(types) && types.length > 0) {
      for (var i = 0; i < types.length; i++) {
        var type = types[i];
        if (types_format.indexOf(type) >= 0) {
          filename = mdfilename.replace(ext, '.' + type);
          var text = editor.document.getText();
          var content = convertMarkdownToHtml(mdfilename, type, text);
          var html = makeHtml(content, uri);
          await exportPdf(html, filename, type, uri);
        } else {
          showErrorMessage('markdownPdf().2 Supported formats: html, pdf, png, jpeg.');
          return;
        }
      }
    } else {
      showErrorMessage('markdownPdf().3 Supported formats: html, pdf, png, jpeg.');
      return;
    }
  } catch (error) {
    showErrorMessage('markdownPdf()', error);
  }
}

function markdownPdfOnSave() {
  try {
    var editor = vscode.window.activeTextEditor;
    var mode = editor.document.languageId;
    if (mode != 'markdown') {
      return;
    }
    if (!isMarkdownPdfOnSaveExclude()) {
      markdownPdf('settings');
    }
  } catch (error) {
    showErrorMessage('markdownPdfOnSave()', error);
  }
}


function isMarkdownPdfOnSaveExclude() {
  try{
    var editor = vscode.window.activeTextEditor;
    var filename = path.basename(editor.document.fileName);
    var patterns = vscode.workspace.getConfiguration('markdown-pdf')['convertOnSaveExclude'] || '';
    var pattern;
    var i;
    if (patterns && Array.isArray(patterns) && patterns.length > 0) {
      for (i = 0; i < patterns.length; i++) {
        pattern = patterns[i];
        var re = new RegExp(pattern);
        if (re.test(filename)) {
          return true;
        }
      }
    }
    return false;
  } catch (error) {
    showErrorMessage('isMarkdownPdfOnSaveExclude()', error);
  }
}


/*
 * convert markdown to html (markdown-it)
 */
function convertMarkdownToHtml(filename, type, text) {
  var grayMatter = require("gray-matter");
  var matterParts = grayMatter(text);

  try {
    try {
      var statusbarmessage = vscode.window.setStatusBarMessage('$(markdown) Converting (convertMarkdownToHtml) ...');
      var hljs = require('highlight.js');
      var breaks = setBooleanValue(matterParts.data.breaks, vscode.workspace.getConfiguration('markdown-pdf')['breaks']);
      var md = require('markdown-it')({
        html: true,
        breaks: breaks,
        highlight: function (str, lang) {

          if (lang && lang.match(/\bmermaid\b/i)) {
            return `<div class="mermaid">${str}</div>`;
          }

          if (lang && hljs.getLanguage(lang)) {
            try {
              str = hljs.highlight(lang, str, true).value;
            } catch (error) {
              str = md.utils.escapeHtml(str);

              showErrorMessage('markdown-it:highlight', error);
            }
          } else {
            str = md.utils.escapeHtml(str);
          }
          return '<pre class="hljs"><code><div>' + str + '</div></code></pre>';
        }
      });
    } catch (error) {
      statusbarmessage.dispose();
      showErrorMessage('require(\'markdown-it\')', error);
    }

  // convert the img src of the markdown
  var cheerio = require('cheerio');
  var defaultRender = md.renderer.rules.image;
  md.renderer.rules.image = function (tokens, idx, options, env, self) {
    var token = tokens[idx];
    var href = token.attrs[token.attrIndex('src')][1];
    // console.log("original href: " + href);
    if (type === 'html') {
      href = decodeURIComponent(href).replace(/("|')/g, '');
    } else {
      href = convertImgPath(href, filename);
    }
    // console.log("converted href: " + href);
    token.attrs[token.attrIndex('src')][1] = href;
    // // pass token to default renderer.
    return defaultRender(tokens, idx, options, env, self);
  };

  if (type !== 'html') {
    // convert the img src of the html
    md.renderer.rules.html_block = function (tokens, idx) {
      var html = tokens[idx].content;
      var $ = cheerio.load(html);
      $('img').each(function () {
        var src = $(this).attr('src');
        var href = convertImgPath(src, filename);
        $(this).attr('src', href);
      });
      return $.html();
    };
  }

  // checkbox
  md.use(require('markdown-it-checkbox'));

  // emoji
  var emoji_f = setBooleanValue(matterParts.data.emoji, vscode.workspace.getConfiguration('markdown-pdf')['emoji']);
  if (emoji_f) {
    var emojies_defs = require(path.join(__dirname, 'data', 'emoji.json'));
    try {
      var options = {
        defs: emojies_defs
      };
    } catch (error) {
      statusbarmessage.dispose();
      showErrorMessage('markdown-it-emoji:options', error);
    }
    md.use(require('markdown-it-emoji'), options);
    md.renderer.rules.emoji = function (token, idx) {
      var emoji = token[idx].markup;
      var emojipath = path.join(__dirname, 'node_modules', 'emoji-images', 'pngs', emoji + '.png');
      var emojidata = readFile(emojipath, null).toString('base64');
      if (emojidata) {
        return '<img class="emoji" alt="' + emoji + '" src="data:image/png;base64,' + emojidata + '" />';
      } else {
        return ':' + emoji + ':';
      }
    };
  }

  // toc
  // https://github.com/leff/markdown-it-named-headers
  var options = {
    slugify: Slug
  }
  md.use(require('markdown-it-named-headers'), options);

  // markdown-it-container
  // https://github.com/markdown-it/markdown-it-container
  md.use(require('markdown-it-container'), '', {
    validate: function (name) {
      return name.trim().length;
    },
    render: function (tokens, idx) {
      if (tokens[idx].info.trim() !== '') {
        return `<div class="${tokens[idx].info.trim()}">\n`;
      } else {
        return `</div>\n`;
      }
    }
  });

  // PlantUML
  // https://github.com/gmunguia/markdown-it-plantuml
  var plantumlOptions = {
    openMarker: matterParts.data.plantumlOpenMarker || vscode.workspace.getConfiguration('markdown-pdf')['plantumlOpenMarker'] || '@startuml',
    closeMarker: matterParts.data.plantumlCloseMarker || vscode.workspace.getConfiguration('markdown-pdf')['plantumlCloseMarker'] || '@enduml',
    server: vscode.workspace.getConfiguration('markdown-pdf')['plantumlServer'] || ''
  }
  md.use(require('markdown-it-plantuml'), plantumlOptions);

  // markdown-it-include
  // https://github.com/camelaissani/markdown-it-include
  // the syntax is :[alt-text](relative-path-to-file.md)
  // https://talk.commonmark.org/t/transclusion-or-including-sub-documents-for-reuse/270/13
  if (vscode.workspace.getConfiguration('markdown-pdf')['markdown-it-include']['enable']) {
    md.use(require("markdown-it-include"), {
      root: path.dirname(filename),
      includeRe: /:\[.+\]\((.+\..+)\)/i
    });
  }

  statusbarmessage.dispose();
  return md.render(matterParts.content);

  } catch (error) {
    statusbarmessage.dispose();
    showErrorMessage('convertMarkdownToHtml()', error);
  }
}


/*
 * https://github.com/microsoft/vscode/blob/ca4ceeb87d4ff935c52a7af0671ed9779657e7bd/extensions/markdown-language-features/src/slugify.ts#L26
 */
function Slug(string) {
  try {
    var stg = encodeURI(
      string.trim()
            .toLowerCase()
            .replace(/\s+/g, '-') // Replace whitespace with -
            .replace(/[\]\[\!\'\#\$\%\&\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~\`。,、;:?!…—·ˉ¨‘’“”々~‖∶"'`|〃〔〕〈〉《》「」『』.〖〗【】()[]{}]/g, '') // Remove known punctuators
            .replace(/^\-+/, '') // Remove leading -
            .replace(/\-+$/, '') // Remove trailing -
    );
    return stg;
  } catch (error) {
    showErrorMessage('Slug()', error);
  }
}


/*
 * make html
 */
function makeHtml(data, uri) {
  try {

    // get title
    let title = path.basename(uri.fsPath);

    // read base
    let filePath = path.join(myGroundPath,'base','base.html')
    var base = readFile(filePath);

    // read frames
    let frame = '';
    frame += readFrames(uri);

    // read styles
    let style = '';
    style += readStyles(uri);

    // read scripts
    let script = '';
    script += readScripts(uri);

    // read mermaid javascripts
    let mermaidServer = vscode.workspace.getConfiguration('markdown-pdf')['mermaidServer'] || '';
    let mermaid = '<script src=\"' + mermaidServer + '\"></script>';

    // compile html
    let mustache = require('mustache');
    let view = {
      title: title,
      frame: frame,
      style: style,
      script: script,
      content: data,
      mermaid: mermaid
    };
    return mustache.render(base, view);
  } catch (error) {
    showErrorMessage('makeHtml()', error);
  }
}


/*
 * export a html to a html file
 */
function exportHtml(data, filename) {
  fs.writeFile(filename, data, 'utf-8', function (error) {
    if (error) {
      showErrorMessage('exportHtml()', error);
      return;
    }
  });
}


/*
 * export a html to a pdf file (html-pdf)
 */
function exportPdf(data, filename, type, uri) {

  if (!INSTALL_CHECK) {
    return;
  }
  if (!checkPuppeteerBinary()) {
    showErrorMessage('Chromium or Chrome does not exist! \
      See https://github.com/yzane/vscode-markdown-pdf#install');
    return;
  }

  var StatusbarMessageTimeout = vscode.workspace.getConfiguration('markdown-pdf')['StatusbarMessageTimeout'];
  vscode.window.setStatusBarMessage('');
  var exportFilename = getOutputDir(filename, uri);

  return vscode.window.withProgress({
    location: vscode.ProgressLocation.Notification,
    title: '[Markdown PDF]: Exporting (' + type + ') ...'
    }, async () => {
      try {
        // export html
        if (type == 'html') {
          exportHtml(data, exportFilename);
          vscode.window.setStatusBarMessage('$(markdown) ' + exportFilename, StatusbarMessageTimeout);
          return;
        }

        const puppeteer = require('puppeteer-core');
        // create temporary file
        var f = path.parse(filename);
        var tmpfilename = path.join(f.dir, f.name + '_tmp.html');
        exportHtml(data, tmpfilename);
        var options = {
          executablePath: vscode.workspace.getConfiguration('markdown-pdf')['executablePath'] || puppeteer.executablePath(),
          args: ['--lang='+vscode.env.language, '--no-sandbox', '--disable-setuid-sandbox']
          // Setting Up Chrome Linux Sandbox
          // https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md#setting-up-chrome-linux-sandbox
      };
        const browser = await puppeteer.launch(options);
        const page = await browser.newPage();
        await page.goto(vscode.Uri.file(tmpfilename).toString(), { waitUntil: 'networkidle0', timeout:0});
        // generate pdf
        // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions
        if (type == 'pdf') {
          // If width or height option is set, it overrides the format option.
          // In order to set the default value of page size to A4, we changed it from the specification of puppeteer.
          var width_option = vscode.workspace.getConfiguration('markdown-pdf', uri)['width'] || '';
          var height_option = vscode.workspace.getConfiguration('markdown-pdf', uri)['height'] || '';
          var format_option = '';
          if (!width_option && !height_option) {
            format_option = vscode.workspace.getConfiguration('markdown-pdf', uri)['format'] || 'A4';
          }
          var landscape_option;
          if (vscode.workspace.getConfiguration('markdown-pdf', uri)['orientation'] == 'landscape') {
            landscape_option = true;
          } else {
            landscape_option = false;
          }
          var options = {
            path: exportFilename,
            scale: vscode.workspace.getConfiguration('markdown-pdf', uri)['scale'],
            displayHeaderFooter: vscode.workspace.getConfiguration('markdown-pdf', uri)['displayHeaderFooter'],
            headerTemplate: vscode.workspace.getConfiguration('markdown-pdf', uri)['headerTemplate'] || '',
            footerTemplate: vscode.workspace.getConfiguration('markdown-pdf', uri)['footerTemplate'] || '',
            printBackground: vscode.workspace.getConfiguration('markdown-pdf', uri)['printBackground'],
            landscape: landscape_option,
            pageRanges: vscode.workspace.getConfiguration('markdown-pdf', uri)['pageRanges'] || '',
            format: format_option,
            width: vscode.workspace.getConfiguration('markdown-pdf', uri)['width'] || '',
            height: vscode.workspace.getConfiguration('markdown-pdf', uri)['height'] || '',
            margin: {
              top: vscode.workspace.getConfiguration('markdown-pdf', uri)['margin']['top'] || '',
              right: vscode.workspace.getConfiguration('markdown-pdf', uri)['margin']['right'] || '',
              bottom: vscode.workspace.getConfiguration('markdown-pdf', uri)['margin']['bottom'] || '',
              left: vscode.workspace.getConfiguration('markdown-pdf', uri)['margin']['left'] || ''
            }
          }
          await page.pdf(options);
        }

        // generate png and jpeg
        // https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagescreenshotoptions
        if (type == 'png' || type == 'jpeg') {
          // Quality options do not apply to PNG images.
          var quality_option;
          if (type == 'png') {
            quality_option = undefined;
          }
          if (type == 'jpeg') {
            quality_option = vscode.workspace.getConfiguration('markdown-pdf')['quality'] || 100;
          }

          // screenshot size
          var clip_x_option = vscode.workspace.getConfiguration('markdown-pdf')['clip']['x'] || null;
          var clip_y_option = vscode.workspace.getConfiguration('markdown-pdf')['clip']['y'] || null;
          var clip_width_option = vscode.workspace.getConfiguration('markdown-pdf')['clip']['width'] || null;
          var clip_height_option = vscode.workspace.getConfiguration('markdown-pdf')['clip']['height'] || null;
          var options;
          if (clip_x_option !== null && clip_y_option !== null && clip_width_option !== null && clip_height_option !== null) {
            options = {
              path: exportFilename,
              quality: quality_option,
              fullPage: false,
              clip: {
                x: clip_x_option,
                y: clip_y_option,
                width: clip_width_option,
                height: clip_height_option,
              },
              omitBackground: vscode.workspace.getConfiguration('markdown-pdf')['omitBackground'],
            }
          } else {
            options = {
              path: exportFilename,
              quality: quality_option,
              fullPage: true,
              omitBackground: vscode.workspace.getConfiguration('markdown-pdf')['omitBackground'],
            }
          }
          await page.screenshot(options);
        }

        await browser.close();

        // delete temporary file
        var debug = vscode.workspace.getConfiguration('markdown-pdf')['debug'] || false;
        if (!debug) {
          if (isExistsPath(tmpfilename)) {
            deleteFile(tmpfilename);
          }
        }

        vscode.window.setStatusBarMessage('$(markdown) ' + exportFilename, StatusbarMessageTimeout);
      } catch (error) {
        showErrorMessage('exportPdf()', error);
      }
    } // async
  ); // vscode.window.withProgress
}


function isExistsPath(path) {
  if (path.length === 0) {
    return false;
  }
  try {
    fs.accessSync(path);
    return true;
  } catch (error) {
    console.warn(error.message);
    return false;
  }
}

function isExistsDir(dirname) {
  if (dirname.length === 0) {
    return false;
  }
  try {
    if (fs.statSync(dirname).isDirectory()) {
      return true;
    } else {
      console.warn('Directory does not exist!') ;
      return false;
    }
  } catch (error) {
    console.warn(error.message);
    return false;
  }
}

function deleteFile (path) {
  var rimraf = require('rimraf')
  rimraf.sync(path);
}

function getOutputDir(filename, resource) {
  try {
    var outputDir;
    if (resource === undefined) {
      return filename;
    }
    var outputDirectory = vscode.workspace.getConfiguration('markdown-pdf')['outputDirectory'] || '';
    if (outputDirectory.length === 0) {
      return filename;
    }

    // Use a home directory relative path If it starts with ~.
    if (outputDirectory.indexOf('~') === 0) {
      outputDir = outputDirectory.replace(/^~/, os.homedir());
      mkdir(outputDir);
      return path.join(outputDir, path.basename(filename));
    }

    // Use path if it is absolute
    if (path.isAbsolute(outputDirectory)) {
      if (!isExistsDir(outputDirectory)) {
        showErrorMessage(`The output directory specified by the markdown-pdf.outputDirectory option does not exist.\
          Check the markdown-pdf.outputDirectory option. ` + outputDirectory);
        return;
      }
      return path.join(outputDirectory, path.basename(filename));
    }

    // Use a workspace relative path if there is a workspace and markdown-pdf.outputDirectoryRootPath = workspace
    var outputDirectoryRelativePathFile = vscode.workspace.getConfiguration('markdown-pdf')['outputDirectoryRelativePathFile'];
    let root = vscode.workspace.getWorkspaceFolder(resource);
    if (outputDirectoryRelativePathFile === false && root) {
      outputDir = path.join(root.uri.fsPath, outputDirectory);
      mkdir(outputDir);
      return path.join(outputDir, path.basename(filename));
    }

    // Otherwise look relative to the markdown file
    outputDir = path.join(path.dirname(resource.fsPath), outputDirectory);
    mkdir(outputDir);
    return path.join(outputDir, path.basename(filename));
  } catch (error) {
    showErrorMessage('getOutputDir()', error);
  }
}

function mkdir(path) {
  if (isExistsDir(path)) {
    return;
  }
  var mkdirp = require('mkdirp');
  return mkdirp.sync(path);
}

function readFile(filename, encode) {
  if (filename.length === 0) {
    return '';
  }
  if (!encode && encode !== null) {
    encode = 'utf-8';
  }
  if (filename.indexOf('file://') === 0) {
    if (process.platform === 'win32') {
      filename = filename.replace(/^file:\/\/\//, '')
    } else {
      filename = filename.replace(/^file:\/\//, '');
    }
  }
  if (isExistsPath(filename)) {
    return fs.readFileSync(filename, encode);
  } else {
    return '';
  }
}

function convertImgPath(src, filename) {
  try {
    var href = decodeURIComponent(src);
    href = href.replace(/("|')/g, '')
          .replace(/\\/g, '/')
          .replace(/#/g, '%23');
    var protocol = url.parse(href).protocol;
    if (protocol === 'file:' && href.indexOf('file:///') !==0) {
      return href.replace(/^file:\/\//, 'file:///');
    } else if (protocol === 'file:') {
      return href;
    } else if (!protocol || path.isAbsolute(href)) {
      href = path.resolve(path.dirname(filename), href).replace(/\\/g, '/')
                                                      .replace(/#/g, '%23');
      if (href.indexOf('//') === 0) {
        return 'file:' + href;
      } else if (href.indexOf('/') === 0) {
        return 'file://' + href;
      } else {
        return 'file:///' + href;
      }
    } else {
      return src;
    }
  } catch (error) {
    showErrorMessage('convertImgPath()', error);
  }
}



// series of makes.


function makeFrame(filename) {
  try {
    let frame = readFile(filename);
    if (frame) {
      return '\n' + frame + '\n';
    } else {
      return '';
    }
  } catch (error) {
    showErrorMessage("makeFrame()",error);
  }
}


function makeStyle(filename) {
  try {
    let css = readFile(filename);
    if (css) {
      return '\n<style>\n' + css + '\n</style>\n';
    } else {
      return '';
    }
  } catch (error) {
    showErrorMessage('makeStyle()', error);
  }
}


function makeScript(filename) {
  try {
    let script = readFile(filename);
    if (script) {
      return '\n<script>\n' + script + '\n</script>\n';
    } else {
      return '';
    }
  } catch (error) {
    showErrorMessage("makeScript()",error);
  }
}



// series of reads.


function readFrames(uri) {
  try {
    let frame='';
    let filePath='';
    let fileName='';
    let filePlace='';
    let frameList=[];
    let i=0;

    // 0. read the frame of template.
    filePath = path.join(myGroundPath,'template','template.html');
    frame += makeFrame(filePath);
    vscode.window.showInformationMessage("get template.html");

    // 1. read the frame of plugins.
    filePlace = path.join(myGroundPath,'plugins')
    frameList = myPluginList;
    if (frameList && Array.isArray(frameList) && frameList.length > 0) {
      for (i = 0; i < frameList.length; i++) {
        fileName = frameList[i] + ".html";
        filePath = path.join(filePlace,frameList[i],fileName);
        frame += makeFrame(filePath);
        }
        vscode.window.showInformationMessage("get frame plugins");
      }
    
    vscode.window.showInformationMessage("finish readFrames!");
    return frame;
  } catch (error) {
    showErrorMessage('readFrames()', error);
  }
}


function readStyles(uri) {
  try {
    let style='';
    let filePath='';
    let fileName='';
    let filePlace='';
    let styleList=[];
    let i=0;
    let href='';

    var includeDefaultStyles = vscode.workspace.getConfiguration('markdown-pdf')['includeDefaultStyles'];
    var highlightStyle = vscode.workspace.getConfiguration('markdown-pdf')['highlightStyle'] || '';
    var ishighlight = vscode.workspace.getConfiguration('markdown-pdf')['highlight'];

    // 0. read the frame of template.
    filePath = path.join(myGroundPath,'template','template.css');
    style += makeStyle(filePath);
    vscode.window.showInformationMessage("get template.css");

    // 1. read the style of the vscode.
    if (includeDefaultStyles) {
      filePath = path.join(__dirname, 'styles', 'markdown.css');
      style += makeStyle(filePath);
    }

    // 2. read the style of the markdown.styles setting.
    if (includeDefaultStyles) {
      styleList = vscode.workspace.getConfiguration('markdown')['styles'];
      if (styleList && Array.isArray(styleList) && styleList.length > 0) {
        for (i = 0; i < styleList.length; i++) {
          href = fixHref(uri, styleList[i]);
          style += '<link rel=\"stylesheet\" href=\"' + href + '\" type=\"text/css\">';
        }
      }
    }

    // 3. read the style of the highlight.js.
    if (ishighlight) {
      if (highlightStyle) {
        var css = vscode.workspace.getConfiguration('markdown-pdf')['highlightStyle'] || 'github.css';
        filePath = path.join(__dirname, 'node_modules', 'highlight.js', 'styles', css);
        style += makeStyle(filePath);
      } else {
        filePath = path.join(__dirname, 'styles', 'tomorrow.css');
        style += makeStyle(filePath);
      }
    }

    // 4. read the style of the markdown-pdf.
    if (includeDefaultStyles) {
      filePath = path.join(__dirname, 'styles', 'markdown-pdf.css');
      style += makeStyle(filePath);
    }

    // 5. read the style of the markdown-pdf.styles settings.
    styleList = vscode.workspace.getConfiguration('markdown-pdf')['styles'] || '';
    if (styleList && Array.isArray(styleList) && styleList.length > 0) {
      for (i = 0; i < styleList.length; i++) {
        href = fixHref(uri, styleList[i]);
        style += '<link rel=\"stylesheet\" href=\"' + href + '\" type=\"text/css\">';
      }
    }
    // 6. read the frame of plugins.
    filePlace = path.join(myGroundPath,'plugins')
    styleList = myPluginList;
    if (styleList && Array.isArray(styleList) && styleList.length > 0) {
      for (i = 0; i < styleList.length; i++) {
        fileName = styleList[i] + ".css";
        filePath = path.join(filePlace,styleList[i],fileName);
        style += makeStyle(filePath);
        }
        vscode.window.showInformationMessage("get style plugins");
      }
    
    vscode.window.showInformationMessage("finish readFrames!");
    return style;
  } catch (error) {
    showErrorMessage('readStyles()', error);
  }
}


function readScripts(uri) {
  try {
    let script='';
    let filePath='';
    let fileName='';
    let filePlace='';
    let scriptList=[];
    let i=0;

    // 0. read the frame of template.
    filePath = path.join(myGroundPath,'template','template.js');
    script += makeScript(filePath);
    vscode.window.showInformationMessage("get template.js");

    // 1. read the frame of plugins.
    filePlace = path.join(myGroundPath,'plugins')
    scriptList = myPluginList;
    if (scriptList && Array.isArray(scriptList) && scriptList.length > 0) {
      for (i = 0; i < scriptList.length; i++) {
        fileName = scriptList[i] + ".js";
        filePath = path.join(filePlace,scriptList[i],fileName);
        script += makeScript(filePath);
        }
        vscode.window.showInformationMessage("get script plugins");
      }

    vscode.window.showInformationMessage("finish readScripts!");
    return script;
  } catch (error) {
    showErrorMessage('readScripts()', error);
  }
}



/*
 * vscode/extensions/markdown-language-features/src/features/previewContentProvider.ts fixHref()
 * https://github.com/Microsoft/vscode/blob/0c47c04e85bc604288a288422f0a7db69302a323/extensions/markdown-language-features/src/features/previewContentProvider.ts#L95
 *
 * Extension Authoring: Adopting Multi Root Workspace APIs ?E Microsoft/vscode Wiki
 * https://github.com/Microsoft/vscode/wiki/Extension-Authoring:-Adopting-Multi-Root-Workspace-APIs
 */
function fixHref(resource, href) {
  try {
    if (!href) {
      return href;
    }

    // Use href if it is already an URL
    const hrefUri = vscode.Uri.parse(href);
    if (['http', 'https'].indexOf(hrefUri.scheme) >= 0) {
      return hrefUri.toString();
    }

    // Use a home directory relative path If it starts with ^.
    if (href.indexOf('~') === 0) {
      return vscode.Uri.file(href.replace(/^~/, os.homedir())).toString();
    }

    // Use href as file URI if it is absolute
    if (path.isAbsolute(href)) {
      return vscode.Uri.file(href).toString();
    }

    // Use a workspace relative path if there is a workspace and markdown-pdf.stylesRelativePathFile is false
    var stylesRelativePathFile = vscode.workspace.getConfiguration('markdown-pdf')['stylesRelativePathFile'];
    let root = vscode.workspace.getWorkspaceFolder(resource);
    if (stylesRelativePathFile === false && root) {
      return vscode.Uri.file(path.join(root.uri.fsPath, href)).toString();
    }

    // Otherwise look relative to the markdown file
    return vscode.Uri.file(path.join(path.dirname(resource.fsPath), href)).toString();
  } catch (error) {
    showErrorMessage('fixHref()', error);
  }
}

function checkPuppeteerBinary() {
  try {
    // settings.json
    var executablePath = vscode.workspace.getConfiguration('markdown-pdf')['executablePath'] || ''
    if (isExistsPath(executablePath)) {
      INSTALL_CHECK = true;
      return true;
    }

    // bundled Chromium
    const puppeteer = require('puppeteer-core');
    executablePath = puppeteer.executablePath();
    if (isExistsPath(executablePath)) {
      return true;
    } else {
      return false;
    }
  } catch (error) {
    showErrorMessage('checkPuppeteerBinary()', error);
  }
}



/*
 * puppeteer install.js
 * https://github.com/GoogleChrome/puppeteer/blob/master/install.js
 */
function installChromium() {
  try {
    vscode.window.showInformationMessage('[Markdown PDF] Installing Chromium ...');
    var statusbarmessage = vscode.window.setStatusBarMessage('$(markdown) Installing Chromium ...');

    // proxy setting
    setProxy();

    var StatusbarMessageTimeout = vscode.workspace.getConfiguration('markdown-pdf')['StatusbarMessageTimeout'];
    const puppeteer = require('puppeteer-core');
    const browserFetcher = puppeteer.createBrowserFetcher();
    const revision = require(path.join(__dirname, 'node_modules', 'puppeteer-core', 'package.json')).puppeteer.chromium_revision;
    const revisionInfo = browserFetcher.revisionInfo(revision);

    // download Chromium
    browserFetcher.download(revisionInfo.revision, onProgress)
      .then(() => browserFetcher.localRevisions())
      .then(onSuccess)
      .catch(onError);

    function onSuccess(localRevisions) {
      console.log('Chromium downloaded to ' + revisionInfo.folderPath);
      localRevisions = localRevisions.filter(revision => revision !== revisionInfo.revision);
      // Remove previous chromium revisions.
      const cleanupOldVersions = localRevisions.map(revision => browserFetcher.remove(revision));

      if (checkPuppeteerBinary()) {
        INSTALL_CHECK = true;
        statusbarmessage.dispose();
        vscode.window.setStatusBarMessage('$(markdown) Chromium installation succeeded!', StatusbarMessageTimeout);
        vscode.window.showInformationMessage('[Markdown PDF] Chromium installation succeeded.');
        return Promise.all(cleanupOldVersions);
      }
    }

    function onError(error) {
      statusbarmessage.dispose();
      vscode.window.setStatusBarMessage('$(markdown) ERROR: Failed to download Chromium!', StatusbarMessageTimeout);
      showErrorMessage('Failed to download Chromium! \
        If you are behind a proxy, set the http.proxy option to settings.json and restart Visual Studio Code. \
        See https://github.com/yzane/vscode-markdown-pdf#install', error);
    }

    function onProgress(downloadedBytes, totalBytes) {
      var progress = parseInt(downloadedBytes / totalBytes * 100);
      vscode.window.setStatusBarMessage('$(markdown) Installing Chromium ' + progress + '%' , StatusbarMessageTimeout);
    }
  } catch (error) {
    showErrorMessage('installChromium()', error);
  }
}

function showErrorMessage(msg, error) {
  vscode.window.showErrorMessage('ERROR: ' + msg);
  console.log('ERROR: ' + msg);
  if (error) {
    vscode.window.showErrorMessage(error.toString());
    console.log(error);
  }
}

function setProxy() {
  var https_proxy = vscode.workspace.getConfiguration('http')['proxy'] || '';
  if (https_proxy) {
    process.env.HTTPS_PROXY = https_proxy;
    process.env.HTTP_PROXY = https_proxy;
  }
}

function setBooleanValue(a, b) {
  if (a === false) {
    return false
  } else {
    return a || b
  }
}

function init() {
  try {
    if (checkPuppeteerBinary()) {
      INSTALL_CHECK = true;
    } else {
      installChromium();
    }
  } catch (error) {
    showErrorMessage('init()', error);
  }
}

感想

readCssからmakeStyleが出てくるの違和感がありすぎるのでreadStyleにしてやった,それらと同様の流れでframe(htmlファイル)とscript(jsファイル)を扱えるようにした,あと変更する範囲でvarの使用をなるべくやめさせた,変数宣言で型を明示したくなった,なぜかvscode上でコンソールが表示されなかったがvscode.window.showErrorMessageは役立った,ブラウザでコンソール見るのは大事だと思った

vscode上でmarkdown pdf + katexで速攻で数式をpdf化

準備

とりあえずこれを入れよう

最近のvscodeはデフォルトでmath形式びmdファイルもちゃんと表示してくれるらしい

概要

vscode上でmarkdownをプレビューする時に数式を扱えるmarkdown mathやvscode内蔵のビュワーは,内部でkatexを動かしてレンダリングしながら表示しているらしく,またそのkatexはブラウザやサーバーサイドにおいてhtml+cssで完結して動くため,svgを弄ってmathmlを設定するmathjaxよりも更に軽量なアドオンらしく,そしてそのmarkdown pdfはmarkdownからhtml+cssを経由したのちにpdfに変換している…
つまりhtml+cssレンダリング処理に介入して(オプションを使って)katexを動作させれば,markdownを爆速でpdfへと変換できるね(デフォルトでは何も設定されていないため数式はレンダリングされないよ),という流れ

手順

  • コマンドパレットから拡張機能フォルダを開いてお目当てのtemplate.htmlを探す
    • macならば/Users/[ユーザー名]/.vscode/extensions/yzane.markdown-pdf-1.4.4/template/template.htmlに確実にあるはず,Windows?知らん
  • katex.org
    • をよく読む
  • katex.org
    • をよく読む
  • katex.org
    • をよく読む
  • そして以下をコピペ
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/katex.min.css" integrity="sha384-R4558gYOUz8mP9YWpZJjofhk+zx0AS11p36HnD2ZKj/6JR5z27gSSULCNHIRReVs" crossorigin="anonymous">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/katex.min.js" integrity="sha384-z1fJDqw8ZApjGO3/unPWUPsIymfsJmyrDVWC8Tv/a1HeOtGmkwNd/7xUS0Xcnvsx" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/auto-render.min.js" integrity="sha384-+XBljXPPiv+OzfbB3cVmLHf4hdUFHlWNZN5spNQ7rmHTXpd7WvJum6fIACpNNfIR" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/mathtex-script-type.min.js" integrity="sha384-jiBVvJ8NGGj5n7kJaiWwWp9AjC+Yh8rhZY3GtAX8yU28azcLgoRo4oukO87g7zDT" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/mhchem.min.js" integrity="sha384-UEY9IRPkV+TTTY7nK1wSrfhWPDJy9wr4PmYg3DLPcN5F4NDlIwGZkWtWveKR/45c"  crossorigin="anonymous"></script>
<link href="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/copy-tex.min.css" rel="stylesheet" type="text/css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/contrib/copy-tex.min.js" integrity="sha384-Ep9Es0VCjVn9dFeaN2uQxgGcGmG+pfZ4eBaHxUpxXDORrrVACZVOpywyzvFRGbmv" crossorigin="anonymous"></script>
<script>
    document.addEventListener("DOMContentLoaded", function() {
        renderMathInElement(document.body, {
          delimiters: [
              {left: '$$', right: '$$', display: true},
              {left: '$', right: '$', display: false}
          ],
          throwOnError : false,
          output:"mathml",
          strict: false,
        });
    });
</script>
  • これらを{{{style}}}{{{mermaid}}}の間に(<head><\head>の中に)挟んでおく
  • {{{style}}}{{{mermaid}}}{{{content}}}レンダリング結果を注入するのに必要な起点なので消すと動かなくなる
  • delimiterは好みに書き換えても良いが困らなければこのままで良い(このままの方が良い)
  • 恐らくkatexのheadバージョンが変われば先のページも書き変わるので(srcに載ってるURLにもSSHのオプション引数が設定されているし),気が向いた際にちゃんと確認しておこう

注意点

ブラウザにレンダリングをさせるための工夫

throwOnError : falseoutput:"mathml"が必要,エラーはなるべくは無視してくれる方が良いし,"html"では後述のcopy-tex extensionが動作しなくなる,現状では"mathml"で困ることもなさそうなので

ディスプレイモードで改行を行うための工夫

strict: falseが必要かつ改行には\\\\が必要,前者はkatexの改行制限を外すため,後者は中間生成の時点で働く謎のエスケープ処理を回避するため
もし挙動が変だと思った時は一旦htmlで出力してデバッグするべし,markdown pdfの処理が自分の想定外である場合が大半

アンダースコア_の扱いの工夫

markdown pdfが強調タグ(<em><\em>)に変換しかねない,発生条件は定かではないが|_{}すると50%の確率で<em><\em>のペアに化かしてくる,必ずエスケープした\|\_{}を使う癖をつけるべし

字体の変化の正当な手順

現在のlatexでは\rm{}が正当なやり方とされているが,katexでは{\rm }を使わないと変な処理が起きて困る,理由は不明だが古い処理系を使っているせいと思われる

その他の環境を使いたい

ディスプレイモードに限定されるが,$$で囲った中身でbegin{xxx}end{xxx}を使う必要がある,そこを直で呼び出せるmathjaxとは訳が違うらしい
例)

$$
  \begin{align}
    x &= y \\\\
    &= z
  \end{align}
$$

もちろん改行には\\\\を使う

拡張機能

恐らくこれで全部のはず

mathJax後方互換

mathtex-script-type.min.jsを使う
mathタグで有効化することでmathjaxをまんま置き換えられるらしい

化学記法互換

mhchem.min.jsを使う
化学に関する記法を追加するらしい

mathmlコピペ互換

copy-tex.min.csscopy-tex.min.jsを使う
mdからhtmlに出力した際にクリップボードからlatex記法のコードをそのまま取り出せるようになる,pdfには対応していないが(ブラウザ上のclipboard apiに相当する機能がないので)htmlで見れる環境なら絶対に採用するべし

vscodeの設定用json + markdown pdf

場所

既定の(デフォルト)設定

所在不明,呼び出しても読み取り専用エディタとして出てくるだけ,どこに実体があるのかはさっぱり…

ユーザー(グローバル)設定

/Users/[ユーザ名]/Library/Application Support/Code/User/settings.json

普段から使い回したい設定は全てこちらに書いておく,latexmk用の起爆ソースとか
デフォルト設定をオーバーライドできる,対象はほぼ全てのオプション

ワークスペース(ウィンドウ)設定

workspace.code-workspace

ワークスペースで限定的に使いたい時に書く,エディタのテーマを切り替えるとか
グローバル設定をオーバーライドできる,対象はほぼ全てのオプション

vscodeワークスペースとは即ちウィンドウ1つのこと,処理のカスタマイズはなるべく書かない方が良い気がする

フォルダー(ローカル)設定

.vscode/settings.json

処理のカスタマイズなど環境を汚染したくない時に書く,今回はココを弄ってみる
ディレクトリに1つだけ作成できて,ワークスペースを含めてそのフォルダを開いたら必ず共有される
なおworkspace.code-workspaceが存在しない場合はなぜかこれがワークスペース設定としてGUIに表示されるし,設定範囲はワークスペース設定と同じになっている,意味不明というか初見のユーザーに優しくないね…

ローカル設定をオーバーライドできる,対象は一部のオプション(ここでしか変えられないものもあるみたいだが…)

使い所

試しにmarkdown pdfの書式を弄ってみる

以下コピペ

書式の一例,.vscode/settings.jsonに以下をコピペして成形すると良い

{
    "markdown-pdf.headerTemplate":
        "<div style=\"font-size: 12px; margin:0 12px 0 12px; width:60%;\"><span style=\"float: left;\">ここにタイトル</span></div> <div style=\"font-size: 12px; margin:0 12px 0 12px; width:30%;\"><span style=\"float: left;\">ここに名前</span></div> <div style=\"font-size: 12px; margin:0 12px 0 12px; width:10%;\"><span style=\"float: right;\" class='date'></span></div>",
    "markdown-pdf.footerTemplate":
        "<div style=\"font-size: 12px; margin:0 12px 0 12px; width:60%;\"><span style=\"float: right;\">ここに署名</span></div> <div style=\"font-size: 9px; margin:0 12px 0 12px; width:40%;\" ><span class='pageNumber'></span> ページ目 / <span class='totalPages'></span> ページ中</div>"
}

どうやら環境変数や引数をブチ込むみたいな芸当はこれだけでは無理らしい(できるの?)

バグ?

<span class='pageNumber'></span><span class='totalPages'></span>は現在ページと合計ページをそれぞれ出力する,しかし内外からその文字サイズを9pxより大きく設定してしまうとなぜか表示されなくなって詰む
また最外の<div>に上下向きのmarginを設定するとレイアウトが崩れてフッタが本文に隠されてしまい詰む,それどころかpaddingしても詰むし文字が大きすぎても詰むので,あまり大きく弄らない方が吉

前者はmarkdown pdfがやりたいhtml+cssにおける動的な注入に対して不都合であり,どうやらmarkdown-pdfのさらに実装元のプラグインであるpuppeteerにまで問題が遡るらしい,"puppeteer"+"pageNumber"+"font size"で検索してみれば「10pxにすると出ますよ」「9pxにすると出ますよ」みたいな内容が見つかる

後者はmarkdown-pdf > margin:bottommarkdown-pdf > margin:topの値を弄れば解決するが,あんまり大きくても見栄えが悪いし何ならタイトルページは別に作れば良いので,なるべくはスマートになるよう心がけよう

LaTeXとの比較

Markdownなので機能が軽いとは言えど学習コストも抑えられて設定も楽でコンパイルも手間や時間がかからずhtmlもodfも生成できると考えるとクッソ良い(個人の感想)

サンプル

htmlの時点でこんな感じ
見出しは6段階までらしい

example
example

# welcome

## to

### my

#### slightly

##### beautiful

###### dream

####### ...?


OK.

````zsh
sudo rm -rf /
````

$$
  e^{i \pi} + 1 = 0
$$

$$
\begin{pmatrix}
  1 & 0 \\\\
  0 & 1
\end{pmatrix} \\\\
against, \\\\
\begin{vmatrix}
  1 & 0 \\\\
  0 & 1
\end{vmatrix}
$$

この世をば 我が世とぞ思ふ 望月の

欠けたることも 無しと思へば
body {
    color: white;
    background-color: black
}
{
    "markdown-pdf.headerTemplate":
        "<div style=\"font-size: 12px; margin:0 12px 0 12px; width:60%;\"><span style=\"float: left;\">this is my report</span></div> <div style=\"font-size: 12px; margin:0 12px 0 12px; width:30%;\"><span style=\"float: left;\">Soluna Eureka</span></div> <div style=\"font-size: 12px; margin:0 12px 0 12px; width:10%;\"><span style=\"float: right;\" class='date'></span></div>",
    "markdown-pdf.footerTemplate":
        "<div style=\"font-size: 12px; margin:0 12px 0 12px; width:60%;\"><span style=\"float: right;\">tuned by S.E.</span></div> <div style=\"font-size: 9px; margin:0 12px 0 12px; width:40%;\" ><span class='pageNumber'></span> page of <span class='totalPages'></span> pages </div>",
    "markdown-pdf.margin.top": "2cm",
    "markdown-pdf.margin.bottom": "2cm",
    "markdown-pdf.highlightStyle": "night-owl.css",
    "markdown-pdf.styles": [
        "./change.css"
    ],
    "markdown-pdf.type": [
        "pdf",
        "html"
    ],
    "markdown-pdf.orientation": "landscape",
    "markdown-pdf.convertOnSave": true,
}

なんと無理矢理に横長にできてしまうし背景色もコードブロックのスタイルも弄れるのだ

だがpdfにすると余白領域が真っ白のまま,ダサ過ぎないか…?

example2
example2

コアのカスタマイズ

タイムアウトの阻止

いくらmdファイルが長くともhtmlレンダリングは一瞬で終わるが,pdfレンダリングは処理時間が30000ms(30秒)をオーバーして強制停止されることでお目当てのブツがエクスポートされないケースがある.だいたいpuppeteerの仕様のせいだが元々は異常動作をさせないためにかけられた防御であって,これをクリアするには手動でtimeout時間を0に設定する必要がある(その代わりセキュリティリスクは高まるらしいが).

github.com

具体的には/Users/[ユーザ名]/.vscode/extensions/yzane.markdown-pdf-[バージョン表記]/extension.jsの407行目あたりで

await page.goto(vscode.Uri.file(tmpfilename).toString(), { waitUntil: 'networkidle0', timeout:0});

要はawaitの呼び出し先の関数page.gotoの引数にtimeout:0を入れてやれば良い.
行数が違くても下の行に

// generate pdf
// https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pagepdfoptions

とか書いてあるしわかりやすいと思われる.つーか多分このことを言ってるんじゃないかな…?

github.com

せや!結局は`streamlink`で`ffmpeg`を使えばええ!

準備

ffmpegstreamlinkが依存してる)とstreamlinkbrewで入れる

formulae.brew.sh

formulae.brew.sh

brew install ffmpeg

brew install streamlink

以下の通り環境変数PATHffmpegがないと死なので確認する

man streamlink
...
 FFmpeg options

       --ffmpeg-ffmpeg FILENAME
              FFMPEG is used to access or mux separate video and audio streams. You can specify the location of the
              ffmpeg executable if it is not in your PATH.

              Example: "/usr/local/bin/ffmpeg"
...

使い方

hlsでも非hlsでも大丈夫

  1. 対象のwebサービスに行って動画を再生して
  2. 「webインスベクタ」を表示,「ネットワーク」を表示
  3. 頑張って拡張子が.m3u8.mp4のファイルを検索
  4. 前者がhls,後者が非hls
  5. そのファイルを落とした時のcURLをコピー
  6. cURLから標準urlだけを抽出
  7. streamlink '[url]' best -o temp
  8. ffmpeg -i tmp -c copy file.mp4

何が起きてるか

streaming系サービスでのhttp通信をうまいこと管理して,hls処理ができるffmpegに渡してくれてる
ffmpegに不足してるhttp管理機能とcurlに不足してるhls処理機能を両立してくれてこれは…ありがたい

細かいオプション

ffmpeg.org

streamlink.github.io

ニコ動のオプション機能でログイン可能とかセッションID持ち込みとかあったりするらしいな

利点!

独立してセッションを構築してくれるからクッソ楽
もしそれができない事情があるなら適宜にhttpのヘッダ情報を引数で渡せば良いので問題はない

欠点?

処理をせず.m3u8+.tsの形式に落とすのは想定外の動作
hlsだと.mp4になったりならなかったりするのでもっかいffmpegをかける習慣をつけよう
まぁ変換前の形式で落とす必要性もなさそうだし,なんならhlsへの変換もやれるっぽいし(今回はそこには触れないけど)

現行のmacOSで使えるデータの圧縮・暗号

圧縮と暗号は大事

匿名化に加えてこれら3つは3大PCリテラシーだと思います

パスワード付きPDF

ちょっとした書類ならコレで良いと思う

  • プレビューで何らかをpdfを保存するときに
  • 「アクセス権」→「書類を開くときにパスワードを要求」にチェック
  • 専用パスワードを確実に打ちこむ
  • 「アクセス権」の設定でさらに改竄防止を見込める
    • 例外的な許可を出さずにパスワード設定すればおk

PDFの仕様なんてそうそう変わらんし,深くは追求しません,汎用性も低いでしょうし…

パスワード付きで暗号と圧縮

ちょっとしたデータならコレで良いと思う
基本的にFInderからGUI経由で行うことが無理な(オプションが呼び出せない)上に,macOSにデフォルトで入ってるアプリやコマンドは更新性や追従性に乏しく,これはhomebrew経由で外部のツールを導入した方が良いと思う

準備

とりあえずは基本のziprartarを抑えておきたい

formulae.brew.sh

formulae.brew.sh

formulae.brew.sh

えっunrarってつい最近ライセンス問題で公式から消えたらしいですよ

qiita.com

formulae.brew.shuntar は存在しない,動作は全部コマンドで決め打ちしているらしい

  • 通常のbrew環境が構築されてるintel macが前提
    • apple siliconでのhomebrewは色々とパスが違ってくるらしいけど知らん
      • とりあえずRosetta2でやればいいんじゃないのか?
  • コマンド設定
    • 以下の順番を守って取得
      1. brew install zip
      2. brew install unzip
      3. brew install rar
      4. brew install carlocab/personal/unrar
      5. brew install gnu-tar
  • パスを設定
    • rarunrarはデフォルトで/usr/local/binエイリアスが発生する
    • targtarとして/usr/local/binエイリアスがある
    • zipunzipmacデフォルトと衝突すると厄介なことになるらしくエイリアスが生成されていない
      • gzipgzipとして別にあるせいで名前が被ってるせいかもしれんね
    • なので/usr/local/opt/binを作ってパスに通した上で/usr/local/opt/[cask name]/bin/[cask command]シンボリックリンクを置く
      • ついでに同じようにtarも置いとこう
      • mkdir /usr/local/opt/bin
      • ln -s /usr/local/opt/zip/bin/zip /usr/local/opt/bin/zip
      • ln -s /usr/local/opt/unzip/bin/unzip /usr/local/opt/bin/unzip
      • ln -s /usr/local/opt/gnu-tar/bin/gtar /usr/local/opt/bin/tar
      • echo export PATH='/usr/local/opt/bin:$PATH' >> ~/.bashrc
    • 私はPATH = [application path]/usr/local/bin:/usr/local/opt/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbinみたいにしてる
    • 各自のシェルの設定ファイルに合わせること,zshなら~/zprofile~/.zshrcになる
      • bashzshの間なら複製して名前を変えるだけでも割と行けるっぽい…?

実行

zipの場合

  • 暗号
    • zip -A -o -r -n .zip:.bz2:.7z:.rar:.gz:.tgz -X -y -8 -e [zipped file path] [source file path]
      • 例:zip -A -o -r -n .zip:.bz2:.7z:.rar:.gz:.tgz -X -y -8 -e ~/Documents/Private/data.zip ~/Download/list
    • パスワード設定はダブルチェックされる
    • オプションはそれぞれ,
      • -AWindows用の自己解凍.exeファイルが生成
        • つまりmacOSには関係ねぇ
      • -oで出てくる.zipの日付がディレクトリ内の日付の最新版に
      • -r再帰処理にしてディレクトリに対応
      • -n [.extension 1]:[.extension num]で2重圧縮を回避
      • -Xファイルシステムの余計な情報を削除
      • -yで参照先の外部ファイルの混入を阻止
      • -8で圧縮強度を最大化
      • -eで本体を暗号化
    • [zipped file path]のファイル名はそのまま出てくるので末尾に.zipを忘れないように
  • 分割
    • zip -s [num][kmgt] [source file path] -O [zipped file path]
      • 例:zip -s 64k ~/Documents/Private/data.zip -O ~/Documents/Private/spirit.zip
    • 1発目の.zip生成の段階で分割しようとするとエラーを吐くのでNG
    • [source file path]ファイル名は.zipじゃないと認識してくれないので注意
    • パスワードチェックなし
    • オプションはそれぞれ,
      • -s [num][k,m,g,t]で分割サイズを指定,最低でも64kbyte以上
  • 結合
    • zip -s 0 [source file path] -O [zipped file path]
      • 例:zip -s 0 ~/Documents/Private/spirit.zip -O ~/Documents/Private/data2.zip
    • -s 0で1つの.zipに還元される
      • 分割と結合を繰り返すとデータがバグるかもしれん
    • パスワードチェックなし
    • [source file path]には分割時の代表ファイル(.z[num]じゃないもの)を指定する
      • もちろん例に漏れず.zipじゃないと認識してくれない
  • 復号
    • unzip [source file path]
      • 例:unzip ~/Documents/Private/data2.zip
      • 特定のファイル1つを解凍する場合はunzip -p [source file path] > [unzipped file path]で名前指定も可能
        • -pで出力をstdoutに指定してパイプ化しただけ
      • 別のディレクトリに入れる場合はunzip [source file path] -d [any directory path]で押し込める
        • -dとその引数は必ず最後に置くこと
    • パスワードチェック必須
    • .zipじゃなくても読み込んでくれる(中身がダメならダメと言ってくれる)
    • 名前がダブったファイルの強制上書き許可は-o・強制上書き禁止は-n
      • unzip -B [source file path]すると上書きはせずにバージョン別(拡張子を含むファイル名の末尾に~[num])のバックアップが追加される
        • 解凍ファイルの内部の処理ではなく,解凍された中身が生成される該当のディレクトリに
      • これ自体にバージョン管理機能はなく,やりたいなら番号をワイルドカードで全検索して抽出しろって感じか?
      • -oするとそのバックアップすらも上書きできるので注意
    • 先の通りmacOSでは自己解凍はできない

ここでは単純に圧縮と展開のやり方を示したが,本来はアーカイブ更新機能の側面が強く,元ディレクトリとのファイルの差分をとって更新して保存するやり方が一般的かもしれない.基本的に.zipにはロックをかけずアップデートをかけ続ける,それを自動化して低リソースな環境で実行する…みたいな雰囲気を感じる.
そこらが関係するのか軽い気持ちで調べてたら想像以上に複雑だったし,私が望む「使える・壊れない・明かされない」という観点でもzipよりrarの方が良かったりする,そのためアーカイブ更新機能として使いこなすのはまた別の機会にしたい.

rarの場合

  • 暗号
    • rar a -k -r -ts- -s -ds -sfx -ma5 -m5 -hp [rarred file path] [source file path]
    • rar a -k -r -ts- -s -ds -sfx -ma5 -m5 -hp ~/Documents/Private/data.sfx ~/Download/list
    • パスワード設定はダブルチェックされる
    • オプションはそれぞれ,
      • aアーカイブ生成モードを指定
      • -kでロックして再編集を不可能に
      • -rで再起処理にしてディレクトリに対応
      • -ts-で時間系の情報を削除
      • -sでソリッド圧縮を指定
      • -dsで内部の名前順ソートを無効化
      • -sfxで自己解凍実行ファイルを添付
      • -ma5で新しい.rar規格を使用
      • -m5で圧縮強度を最大化
      • -hpでヘッダごと本体を暗号化
    • -sfxの場合は拡張子.sfxが必ずつくが,ない場合は[rarred file path]がそのまま出てくる
  • 復号
    • unrar x [source file path]
      • 自己解凍させたい場合は./[source file path]すると起動する,手順は変わらない
      • 特定のファイル1つを解凍する場合はunrar p -iunl [source file path] > [unrarred file path]で名前指定も可能
        • -pで出力をstdoutに指定した上で-inulでデフォの出力を消さないとダメ
      • 別のディレクトリに入れる場合はunrar x [source file path] [any directory path+/]で押し込める
        • パスの最後に/が入ってない場合は[source file path]と見なされて処理されないので注意
    • パスワードチェック必須
    • .rarじゃなくても読み込んでくれる(中身がダメならダメと言ってくれる)
    • 名前がダブったファイルの強制上書き許可は-o+・強制上書き禁止は-o-

ロックと暗号化が行えるのが強みで,圧縮性・耐破性・暗号性もzipより上,導入も楽で自爆解凍もできる,しかも文法が簡単で覚えやすい.現実では特に欧州で流行ってるらしく,実際に使っている人間は自分の周りでこそほぼ見たことがないものの,割と積極的に使いたい印象を受けた.もうこれだけでよくね?と言える程度には簡潔に完結していると思う.

zipに関する補記

homebrewで入ってくるzipは「16 June 2008 (v3.0)」かつunzipは「unzip20 April 2009 (v6.0)」が最新版となっている.macOSにデフォルトで入っているzipunzipも全く同じであり,要するにわざわざディレクトリを分けて管理しようとしたのは徒労であったということになる.さらに-Zbzipできるがunzipできない(以下の通りなら.zip規格の対応が0.1だけunzipの方が遅れているせいだと思われる)などのドボン選択肢もある.

% zip -rZb arc.zip test
% unzip arc.zip
... need PK compat. v4.6 (can do v4.5)

またzipにおける「エントリ」とは「.zip内にあるファイルもしくはディレクトリ」を指す.「エントリには65535個の制限があるほか,アーカイブのサイズにも4GBの制限がある」なる昔話があったらしいが.これはかつてのzipが32bit動作をしているからであり,それを改善したzip64はその制限を超えられるようになった.
自分のzipunzipがそれに対応しているかどうかについて,以下のように表示されれば対応の確認が取れる.

% zip -v | grep 64
... ZIP64_SUPPORT
% zip -v | grep 64
... ZIP64_SUPPORT

現在は/usr/bin/unzipもzip64に対応しているようだが,アーカイブユーティリティ.appについてはmacOS Catalinaになるまで実装されなかったという目撃情報がある.

taiyakon.com

そもそもzipunzipの公式サイトを見ても最新版がhomebrew上の最新版やmacOSのデフォルト版と一致しており,

infozip.sourceforge.net

これらはInfo-zipという形で2009年までOSSで開発されていた,今後ここから新しくなることはないと考えて良さそう.
後継としてはzlibgzipか,前者はzipで培われた圧縮技術を各言語・各OSで利用可能にするライブラリで,後者はGNUの開発元でGPLライセンスで提供されるファイル圧縮用のツール,という認識で合っているのだろうか?

準備

rarzipの代わりにbzip2gzip7ziplrzip(・rzip)を利用していく

formulae.brew.sh

formulae.brew.sh

formulae.brew.sh

formulae.brew.sh

formulae.brew.sh

  • コマンド設定
    • 以下を取得
      1. brew install gzip
      2. brew install p7zip
      3. brew install bzip2
      4. brew install lrzip
      5. brew install rzip
  • パスを設定
    • bzip2macデフォルトと衝突すると厄介なことになるらしくエイリアスが生成されていない
      • ln -s /usr/local/opt/bzip2/bin/bzip2 /usr/local/opt/bin/bzip2しましょうね
    • コレ以外は普通に/usr/local/binに入ってくれる
    • rzipは使わないが,archlinux wikiにも載ってたので敬意を表して数字に貢献した
      • lrziprzipの機能を利用した上でさらに色々と工夫したらしいな?

実行

以下は順に追記していきます

gzipの場合

  • 圧縮
    • gzip -k -9 -c [source file path] > [zipped file path]
    • [zipped file path]のファイル名はそのまま出てくるので末尾に.gzを忘れないように
      • -c> [zipped file path]がないと[source file path]のファイル名に勝手に.gzがつくけど
    • オプションはそれぞれ,
      • -kで元ファイルを削除しない
      • -9で圧縮強度を最大化
      • -cstdoutに指定
    • ディレクトリを再帰的に辿る実装はなく,tar(後述)でまとめてからgzipするのが正攻法
      • 引数に複数のファイルを並べると全て個別に.gz化されるのが特徴
      • gzip -k -9 -c [source file path1] > [zipped file path1] [source file path num] > [zipped file path num]すれば良い
        • [source file path1] [source file path num] > [zipped file path]すると,.tar同士だけなら最後に展開すればなんとかなるが,.tar以外のファイルが混ざってると詰む
        • manのADVANCED USAGEは嘘こそ言ってないが,例えば画像ファイルの2つをgzipしてgunzipすると元に戻らない,フォルダ構造を失った挙句それをrescueする方法がないから当然とも言えるが
    • 名前がダブったファイルの強制上書き許可は-f,なければ聞かれるまでもなく上書きされない
  • 解凍
    • gzip2 -k -c [source file path] > [unzipped file path]
    • オプションはそれぞれ,
      • -kで元ファイルを削除しない
      • -cstdoutに指定
    • どうせファイルしか出てこないのでこれで覚えた方が良い
    • 名前がダブったファイルの強制上書き許可は-f,なければ聞かれるまでもなく上書きされない
    • gzipを入れると勝手にgunzipもついてくる
      • というよりgzipを使う上で-zで圧縮するか-dで解凍するかの違いしかない

調べた限りではディレクトリ統合機能もパスワード暗号化機能もなさそう,やはり別ライブラリ(後述)を使うべきだろう.

--rsyncableとは?

tar+gzipで保管されるファイルに対して,生成する際に--rsyncableを用いてgzipしておくことで,そのサーバに対してrsyncした際に得られるデータの差分の量そのものがカットできる(2016年のv1.7で実装されたらしい)

superuser.com

translate.google.com

Rsyncable gzipbeeznest.wordpress.com

beeznest-wordpress-com.translate.goog

rsync -zオプションでも圧縮が可能であるが,こちらは差分を取った後に転送するデータを圧縮するため,要するに2段構えの方が(マシンリソースあるなら)絶対に良いし,zipに比べても大きな利点となる

gzip -rgunzip -rの動作

この-r再帰的という意味を示すが,zip -rは渡されたパスが示すディレクトリを基準として配下のディレクトリ・ファイルをまとめて1つの.zipにする一方,gzip -rでは渡されたパスが示すディレクトリもしくはその配下のディレクトリにファイルがあればその場で1つずつ.gzにしていく,同様に1つずつ.gzから戻していくのがgunzip -rである

p7zipの場合

bzip2の場合

  • 圧縮
    • bzip2 -k -9 -c [source file path] > [zipped file path]
    • [zipped file path]のファイル名はそのまま出てくるので末尾に.bz2を忘れないように
      • -c> [zipped file path]がないと[source file path]のファイル名に勝手に.bz2がつくけど
    • オプションはそれぞれ,
      • -kで元ファイルを削除しない
      • -9で圧縮強度を最大化
      • -cstdoutに指定
    • ディレクトリを再帰的に辿る実装はなく,tar(後述)でまとめてからbzip2するのが正攻法
      • 引数に複数のファイルを並べると全て個別に.bz2化されるのが特徴
        • bzip2 -k -9 -c [source file path1] > [zipped file path1] [source file path num] > [zipped file path num]すれば良い
        • [source file path1] [source file path num] > [zipped file path]すると,.tar同士だけなら最後に展開すればなんとかなるが,.tar以外のファイルが混ざってると詰む
        • gzipとほぼ同じ理由か
    • 名前がダブったファイルの強制上書き許可は-f,なければ聞かれるまでもなく上書きされない
  • 解凍
    • bunzip2 -k -c [source file path] > [unzipped file path]
    • オプションはそれぞれ,
      • -kで元ファイルを削除しない
      • -cstdoutに指定
    • どうせファイルしか出てこないのでこれで覚えた方が良い
    • 名前がダブったファイルの強制上書き許可は-f,なければ聞かれるまでもなく上書きされない
    • bzip2を入れると勝手にbunzip2もついてくる
      • というよりbzip2を使う上で-zで圧縮するか-dで解凍するかの違いしかない

調べた限りではディレクトリ統合機能もパスワード暗号化機能もなさそう,やはり別ライブラリ(後述)を使うべきだろう.

lrzipの場合

そういやunar使わないんすか?

なんか負けた気分になるし手動で詰めた方が個人的にはスッキリするので…

FireVault

Mac起動ディスクだけならコレで良いと思う
Windowsのbitlockerよりも優秀だと感じている

設定したら復旧キーを忘れないように必ずメモしておこう,iCloud連携は正直やりたくないなって…

外部のボリュームを暗号化

外付けストレージならコレで良いと思う
ディスクユーティリティでexFATフォーマットされたドライブの中にも問答無用で設定できる

veracrypt

Windows向けという印象があるがmacOSでも普通に使える
OSS開発でありながらも,これを使えば暗号化はかなり万全と考えて良さそうか…?

adguardのユーザールールで5ちゃんねる用の非表示フィルタを作成

ワッチョイ,IPアドレスコテハン,名前

!wacchoi,ip,name
5ch.net##div:matches-attr("/class/"="/post|post highlightpost|post_hover vis own[0-9]+/") > div > span:matches-attr("/class/"="/name/"):contains(/ここに正規表現で書き込み/):upward(2):remove()

ワッチョイ決め打ち

正規表現,こんな感じで前後に分けて判定

abcd-....|....-efgh

ID

!userID
5ch.net##div:matches-attr("/class/"="/post|post highlightpost|post_hover vis own[0-9]+/") > div > span:matches-attr("/class/"="/uid/"):contains(/ここに正規表現で書き込み/):upward(2):remove()

末尾の決め打ち

正規表現,例として末尾d・末尾a・末尾r・末尾Mを弾く

\b.+[darM]\b

正規表現の仕様

developer.mozilla.org

概ねこれに一致しているかと思います(オプション指定はしなくても良さそう)

鯖毎or板毎に使い分け

ドメインを細かく指定すれば何とかなると思います

iCloud+の「メールを非公開(偽装アドレス)」で実験してみた

以下,偽装アドレスと呼称します

コレに興味がある方は基本的な使い方は大丈夫だと思われまスゥゥゥ…

https://support.apple.com/ja-jp/HT210425

簡単な説明

macOS Monterey(Public Beta版でそこそこ安定しているやつがあるから登録して落としてこよう
設定 → Apple ID → メールを非公開(オプション)に移動
左下の『➕』で自動生成,ラベルとメモを忘れずに書き加える
アドレスをコピペして実際に使う,以上

用語リスト

f:id:Soluna_Eureka:20211008202622p:plain
説明

偽装アドレス

さっきの手順で手に入れたアドレス
xxx@icloud.comとおく

自分アドレス

自分の本来のiCloudのアドレス
XXX@icloud.comとおく

相手アドレス

やり取りをしたい相手のアドレス
YYY@example.ne.jpとでもおくか

串用アドレス

メールの送信先に実際に置かれるアドレス
4つ偽装アドレスで5つくらい取ってから実験してみた
ユーザーの視点では「プロキシが持つアドレス」に見えるのでそう呼びたい…
YYY@example.ne.jpを用いると
YYY_at_example_ne_jp_[aa][nnnnnnnnnn][bb]_[mm][cccccc]@icloud.comみたいに導出される

[aa][bb][cccccc]は同じ偽装アドレスを用いて通信した相手アドレスであれば共通する
[nnnnnnnnnn](10桁)・[mm](2桁)は相手アドレスによって完全にランダムに振られる
なんでこの桁数や構成になったのかは不明…

実際に制限ルールを探ってみた

ルール1.文通するには最初に相手から送られてくる必要がある

相手アドレスから偽装アドレスにメールが飛んでくると,相手アドレスに対応する串用アドレスが発行され,串用アドレスから自分アドレスにメールが飛んでくる(Applesmtpサーバから転送されてくる)
偽装アドレスを用いて相手アドレスにメールを返信するにはその真逆の動作をしなければならず,自分アドレスから串用アドレスに飛ばすことで偽装アドレスから相手アドレスにメールが飛んでいく

すなわち送信アドレスに対応した串側アドレスをユーザーが叩くことで偽装アドレスから相手アドレスへのアクセスが行えるが,串側アドレスの発行は外側から受けたメールを処理するApplesmtpサーバでしかできない以上,こちらから先制して送りつけることは不可能,対応できる串側アドレスがなければユーザーはなにもできない
濫用防止のためと言われれば当然はであるが

ルール2.串側アドレスの乱数文字列がユーザ認証の役目を果たす

試しに1文字だけ弄って送信したらエラーを吐かれたし,偽装アドレスによって串側アドレスのフォーマットの約半分が定まっていることから,自分アドレスと偽装アドレスの権限照合・串側アドレスと相手アドレスの宛先照合が行われているとみて良さそう
予想するに,他のiCloudユーザが他人の串側アドレスを宛先にして送信してもダメだったりするのではないだろうか…?(誰かやってくれ)

ちなみに宛先が間違っていると代替のsmtpサーバに送信され,Undelivered Mail Returned to Serverされる

ルール3.串側アドレスを宛先に含む場合はアドレス1つ分しか対応しない

宛先に2つ以上の串側アドレスを登録して1度に送ろうとすると,

  • Web版iCloudメールでは警告メッセージを吐かれる
    • 「機能的に無理です!」
  • Macデフォルトのメールソフトでは約5分間隔で宛先1つずつに再送される
    • 「too many recipients!」
      • 代替のstmpサーバの指定を要求されるが,何を選んでも結局は再送になる.
    • なお1つ目の宛先にはすぐに送れる
    • 個別送信扱いになるので他の宛先は見えなくなる

または串側アドレスと通常のアドレスを登録して1度に送ろうとすると,

  • Web版iCloudメールでは警告メッセージを吐かれる
    • 「Undelivered Mail Returned to Server」
    • どっちも届かない
  • Macデフォルトのメールソフトでは約5分間隔で宛先1つずつに再送される
    • 代替のstmpサーバの指定を要求される
      • 選ぶとバラバラに(串側アドレスはApplesmtpサーバで,それ以外は代替smtpサーバで)再送される
      • 相手アドレスから見れば宛先の連名は見れないが,通常のアドレスからは丸見え
    • 1つ目の串側アドレスや通常のアドレスの宛先にはすぐに送れる

つまりメールソフトにおける動作としては,

  1. まずはiCloudサーバにまるごと送る
  2. 串側アドレスがある場合は1つしか受け付けられないと返信する
  3. 代替smtpサーバがない場合は全て破棄,ある場合は
    1. 串側アドレス1つをiCloudサーバに送り
    2. その他全てを代替smtpサーバに送る
    3. 串側アドレスが残れば返送されるので
      1. 約5分間隔で1つずつ再送する
  4. エラーが無くなれば完了

という感じ…っぽい

ルール4.串側アドレスはApple側で保持される

以上のルールを満たすには,偽装アドレスのみならず串側アドレスもAppleが管理していると見るべきだろう
というかリンク切れが起きたりしたらこっちも大変なので,そこら辺は頑張ってセーブし続けてほしい月額160円でやっていいサービスか?これが…

感想

今後また仕様が何かしら変わるかも知れません
が,とりあえず串側アドレスには偽装アドレスと一意に結び付けられる成分があることは頭に入れておくべきだと思いました
万が一の場合にはそれが流出することでやり取りが特定される可能性も考えられます
あとはどれだけ串側アドレスが保持されるのかやってみなくちゃわかりません,1年間の未使用でリセットとかありそうですし…

匿名化ヨシ!ご安全に!

全てのきっかけ

https://twitter.com/Soluna_Eureka/status/1446095138808221707?s=20

Akamai vs Cloudflare vs Fastly : (Private Relayの) 出口プロキシ が NXDOMAIN かどうか

言いたいこと

Apple iCloud+のPrivate Relayは擬似VPNとでも言えるような2段プロキシを採用していますが, 日本の東京エリアではAkamaiとCloudflareとFastlyの3社が分担でが出口プロキシを担当しているようで, これは事前に出さたIPリストからwhoisすることで確認できます.
(本来は地域別のIPアドレス割り当ての対応表リストという役割がありますが,現状ではここ以外から通信することは不可能と思われる以上,これが実質的に出口プロキシのリストそのものになっています.)

同様にそのIPアドレスnslookupを仕掛ければ,Akamaiサーバだけは

a[ppp-qqq-rrr-sss].deploy.static.akamaitechnologies.com
/* [ppp-qqq-rrr-sss] は出口プロキシが利用可能なipv4アドレス */

と解決ますが,CloudflareサーバとFastlyサーバではnxdomainを返されます.

1つの同じサービスにどのような理屈・過程で複数の仕様ができてしまったのか,その理由を知りたいと考えています.
特に一部のサービスではAkamaiサーバにのみ規制がかかっている (5ch.netのburned bbq:proxy(60)など) ため, この (恐らくセキュリティ関係の) 設定の差が不便を生んだのではないかと疑っています.

加えて,Appleは出口プロキシのあり方についてどう考えているのか,DNSレコードに乗らないことをよしとしているのか, または近い将来に仕様が統一されるかユーザーが選択できるか,といったことについても知れたら良いなと思っています.

おまけ

東京の出口プロキシのIPのリスト(CSV)を作ったよ!

一覧

172.224.240.128/27,Akamai
172.225.46.64/26,Akamai
172.225.46.208/28,Akamai
172.225.48.0/26,Akamai
172.225.48.128/28,Akamai
172.225.52.176/28,Akamai
172.225.54.128/26,Akamai
172.225.74.128/26,Akamai
172.225.75.0/24,Akamai
172.225.76.0/24,Akamai
172.225.122.0/25,Akamai
172.225.122.192/27,Akamai
172.225.123.0/25,Akamai
172.225.123.128/27,Akamai
172.225.124.192/27,Akamai
172.225.127.0/25,Akamai
172.226.24.0/25,Akamai
172.226.24.128/27,Akamai
172.226.42.0/25,Akamai
172.226.54.0/26,Akamai
172.226.54.64/28,Akamai
172.226.56.0/26,Akamai
172.226.56.64/28,Akamai
172.226.58.0/26,Akamai
172.226.58.64/28,Akamai
2a02:26f7:b980::/42,Akamai
104.28.0.45/32,Cloudflare
104.28.0.46/32,Cloudflare
104.28.4.73/32,Cloudflare
104.28.4.74/32,Cloudflare
104.28.44.37/32,Cloudflare
104.28.44.38/32,Cloudflare
104.28.44.39/32,Cloudflare
104.28.44.40/32,Cloudflare
104.28.44.41/32,Cloudflare
104.28.44.42/32,Cloudflare
104.28.44.43/32,Cloudflare
104.28.44.44/32,Cloudflare
104.28.44.45/32,Cloudflare
104.28.44.46/32,Cloudflare
104.28.44.47/32,Cloudflare
104.28.44.48/32,Cloudflare
104.28.44.49/32,Cloudflare
104.28.44.50/32,Cloudflare
104.28.44.51/32,Cloudflare
104.28.44.52/32,Cloudflare
104.28.44.53/32,Cloudflare
104.28.44.54/32,Cloudflare
104.28.44.55/32,Cloudflare
104.28.44.56/32,Cloudflare
104.28.44.57/32,Cloudflare
104.28.44.58/32,Cloudflare
104.28.44.59/32,Cloudflare
104.28.44.60/32,Cloudflare
104.28.44.61/32,Cloudflare
104.28.44.62/32,Cloudflare
104.28.44.63/32,Cloudflare
104.28.44.64/32,Cloudflare
104.28.44.65/32,Cloudflare
104.28.44.66/32,Cloudflare
104.28.44.67/32,Cloudflare
104.28.44.68/32,Cloudflare
104.28.44.69/32,Cloudflare
104.28.44.70/32,Cloudflare
104.28.44.71/32,Cloudflare
104.28.44.72/32,Cloudflare
104.28.44.73/32,Cloudflare
104.28.44.74/32,Cloudflare
104.28.67.170/32,Cloudflare
104.28.67.171/32,Cloudflare
104.28.67.172/32,Cloudflare
104.28.67.173/32,Cloudflare
104.28.67.174/32,Cloudflare
104.28.67.175/32,Cloudflare
104.28.67.176/32,Cloudflare
104.28.67.177/32,Cloudflare
104.28.67.178/32,Cloudflare
104.28.67.179/32,Cloudflare
104.28.67.180/32,Cloudflare
104.28.67.181/32,Cloudflare
104.28.67.182/32,Cloudflare
104.28.67.183/32,Cloudflare
104.28.67.184/32,Cloudflare
104.28.67.185/32,Cloudflare
104.28.67.186/32,Cloudflare
104.28.67.187/32,Cloudflare
104.28.67.188/32,Cloudflare
104.28.67.189/32,Cloudflare
104.28.67.190/32,Cloudflare
104.28.67.191/32,Cloudflare
104.28.67.192/32,Cloudflare
104.28.67.193/32,Cloudflare
104.28.67.194/32,Cloudflare
104.28.67.195/32,Cloudflare
104.28.67.196/32,Cloudflare
104.28.67.197/32,Cloudflare
104.28.67.198/32,Cloudflare
104.28.67.199/32,Cloudflare
104.28.67.200/32,Cloudflare
104.28.67.201/32,Cloudflare
104.28.67.202/32,Cloudflare
104.28.67.203/32,Cloudflare
104.28.67.204/32,Cloudflare
104.28.67.205/32,Cloudflare
104.28.67.206/32,Cloudflare
104.28.67.207/32,Cloudflare
104.28.70.170/32,Cloudflare
104.28.70.171/32,Cloudflare
104.28.70.172/32,Cloudflare
104.28.70.173/32,Cloudflare
104.28.70.174/32,Cloudflare
104.28.70.175/32,Cloudflare
104.28.70.176/32,Cloudflare
104.28.70.177/32,Cloudflare
104.28.70.178/32,Cloudflare
104.28.70.179/32,Cloudflare
104.28.70.180/32,Cloudflare
104.28.70.181/32,Cloudflare
104.28.70.182/32,Cloudflare
104.28.70.183/32,Cloudflare
104.28.70.184/32,Cloudflare
104.28.70.185/32,Cloudflare
104.28.70.186/32,Cloudflare
104.28.70.187/32,Cloudflare
104.28.70.188/32,Cloudflare
104.28.70.189/32,Cloudflare
104.28.70.190/32,Cloudflare
104.28.70.191/32,Cloudflare
104.28.70.192/32,Cloudflare
104.28.70.193/32,Cloudflare
104.28.70.194/32,Cloudflare
104.28.70.195/32,Cloudflare
104.28.70.196/32,Cloudflare
104.28.70.197/32,Cloudflare
104.28.70.198/32,Cloudflare
104.28.70.199/32,Cloudflare
104.28.70.200/32,Cloudflare
104.28.70.201/32,Cloudflare
104.28.70.202/32,Cloudflare
104.28.70.203/32,Cloudflare
104.28.70.204/32,Cloudflare
104.28.70.205/32,Cloudflare
104.28.70.206/32,Cloudflare
104.28.70.207/32,Cloudflare
104.28.83.195/32,Cloudflare
104.28.83.196/32,Cloudflare
104.28.83.197/32,Cloudflare
104.28.83.198/32,Cloudflare
104.28.83.199/32,Cloudflare
104.28.83.200/32,Cloudflare
104.28.83.201/32,Cloudflare
104.28.83.202/32,Cloudflare
104.28.83.203/32,Cloudflare
104.28.83.204/32,Cloudflare
104.28.83.205/32,Cloudflare
104.28.83.206/32,Cloudflare
104.28.83.207/32,Cloudflare
104.28.83.208/32,Cloudflare
104.28.83.209/32,Cloudflare
104.28.83.210/32,Cloudflare
104.28.83.211/32,Cloudflare
104.28.83.212/32,Cloudflare
104.28.83.213/32,Cloudflare
104.28.83.214/32,Cloudflare
104.28.83.215/32,Cloudflare
104.28.83.216/32,Cloudflare
104.28.83.217/32,Cloudflare
104.28.83.218/32,Cloudflare
104.28.83.219/32,Cloudflare
104.28.83.220/32,Cloudflare
104.28.83.221/32,Cloudflare
104.28.83.222/32,Cloudflare
104.28.83.223/32,Cloudflare
104.28.83.224/32,Cloudflare
104.28.83.225/32,Cloudflare
104.28.83.226/32,Cloudflare
104.28.83.227/32,Cloudflare
104.28.83.228/32,Cloudflare
104.28.83.229/32,Cloudflare
104.28.83.230/32,Cloudflare
104.28.83.231/32,Cloudflare
104.28.83.232/32,Cloudflare
104.28.99.191/32,Cloudflare
104.28.99.192/32,Cloudflare
104.28.99.193/32,Cloudflare
104.28.99.194/32,Cloudflare
104.28.99.195/32,Cloudflare
104.28.99.196/32,Cloudflare
104.28.99.197/32,Cloudflare
104.28.99.198/32,Cloudflare
104.28.99.199/32,Cloudflare
104.28.99.200/32,Cloudflare
104.28.99.201/32,Cloudflare
104.28.99.202/32,Cloudflare
104.28.99.203/32,Cloudflare
104.28.99.204/32,Cloudflare
104.28.99.205/32,Cloudflare
104.28.99.206/32,Cloudflare
104.28.99.207/32,Cloudflare
104.28.99.208/32,Cloudflare
104.28.99.209/32,Cloudflare
104.28.99.210/32,Cloudflare
104.28.99.211/32,Cloudflare
104.28.99.212/32,Cloudflare
104.28.99.213/32,Cloudflare
104.28.99.214/32,Cloudflare
104.28.99.215/32,Cloudflare
104.28.99.216/32,Cloudflare
104.28.99.217/32,Cloudflare
104.28.99.218/32,Cloudflare
104.28.99.219/32,Cloudflare
104.28.99.220/32,Cloudflare
104.28.99.221/32,Cloudflare
104.28.99.222/32,Cloudflare
104.28.99.223/32,Cloudflare
104.28.99.224/32,Cloudflare
104.28.99.225/32,Cloudflare
104.28.99.226/32,Cloudflare
104.28.99.227/32,Cloudflare
104.28.99.228/32,Cloudflare
104.28.101.191/32,Cloudflare
104.28.101.192/32,Cloudflare
104.28.101.193/32,Cloudflare
104.28.101.194/32,Cloudflare
104.28.101.195/32,Cloudflare
104.28.101.196/32,Cloudflare
104.28.101.197/32,Cloudflare
104.28.101.198/32,Cloudflare
104.28.101.199/32,Cloudflare
104.28.101.200/32,Cloudflare
104.28.101.201/32,Cloudflare
104.28.101.202/32,Cloudflare
104.28.101.203/32,Cloudflare
104.28.101.204/32,Cloudflare
104.28.101.205/32,Cloudflare
104.28.101.206/32,Cloudflare
104.28.101.207/32,Cloudflare
104.28.101.208/32,Cloudflare
104.28.101.209/32,Cloudflare
104.28.101.210/32,Cloudflare
104.28.101.211/32,Cloudflare
104.28.101.212/32,Cloudflare
104.28.101.213/32,Cloudflare
104.28.101.214/32,Cloudflare
104.28.101.215/32,Cloudflare
104.28.101.216/32,Cloudflare
104.28.101.217/32,Cloudflare
104.28.101.218/32,Cloudflare
104.28.101.219/32,Cloudflare
104.28.101.220/32,Cloudflare
104.28.101.221/32,Cloudflare
104.28.101.222/32,Cloudflare
104.28.101.223/32,Cloudflare
104.28.101.224/32,Cloudflare
104.28.101.225/32,Cloudflare
104.28.101.226/32,Cloudflare
104.28.101.227/32,Cloudflare
104.28.101.228/32,Cloudflare
104.28.118.164/32,Cloudflare
104.28.118.165/32,Cloudflare
104.28.118.166/32,Cloudflare
104.28.118.167/32,Cloudflare
104.28.118.168/32,Cloudflare
104.28.118.169/32,Cloudflare
104.28.118.170/32,Cloudflare
104.28.118.171/32,Cloudflare
104.28.118.172/32,Cloudflare
104.28.118.173/32,Cloudflare
104.28.118.174/32,Cloudflare
104.28.118.175/32,Cloudflare
104.28.118.176/32,Cloudflare
104.28.118.177/32,Cloudflare
104.28.118.178/32,Cloudflare
104.28.118.179/32,Cloudflare
104.28.118.180/32,Cloudflare
104.28.118.181/32,Cloudflare
104.28.118.182/32,Cloudflare
104.28.118.183/32,Cloudflare
104.28.118.184/32,Cloudflare
104.28.118.185/32,Cloudflare
104.28.118.186/32,Cloudflare
104.28.118.187/32,Cloudflare
104.28.118.188/32,Cloudflare
104.28.118.189/32,Cloudflare
104.28.118.190/32,Cloudflare
104.28.118.191/32,Cloudflare
104.28.118.192/32,Cloudflare
104.28.118.193/32,Cloudflare
104.28.118.194/32,Cloudflare
104.28.118.195/32,Cloudflare
104.28.118.196/32,Cloudflare
104.28.118.197/32,Cloudflare
104.28.118.198/32,Cloudflare
104.28.118.199/32,Cloudflare
104.28.118.200/32,Cloudflare
104.28.118.201/32,Cloudflare
104.28.121.164/32,Cloudflare
104.28.121.165/32,Cloudflare
104.28.121.166/32,Cloudflare
104.28.121.167/32,Cloudflare
104.28.121.168/32,Cloudflare
104.28.121.169/32,Cloudflare
104.28.121.170/32,Cloudflare
104.28.121.171/32,Cloudflare
104.28.121.172/32,Cloudflare
104.28.121.173/32,Cloudflare
104.28.121.174/32,Cloudflare
104.28.121.175/32,Cloudflare
104.28.121.176/32,Cloudflare
104.28.121.177/32,Cloudflare
104.28.121.178/32,Cloudflare
104.28.121.179/32,Cloudflare
104.28.121.180/32,Cloudflare
104.28.121.181/32,Cloudflare
104.28.121.182/32,Cloudflare
104.28.121.183/32,Cloudflare
104.28.121.184/32,Cloudflare
104.28.121.185/32,Cloudflare
104.28.121.186/32,Cloudflare
104.28.121.187/32,Cloudflare
104.28.121.188/32,Cloudflare
104.28.121.189/32,Cloudflare
104.28.121.190/32,Cloudflare
104.28.121.191/32,Cloudflare
104.28.121.192/32,Cloudflare
104.28.121.193/32,Cloudflare
104.28.121.194/32,Cloudflare
104.28.121.195/32,Cloudflare
104.28.121.196/32,Cloudflare
104.28.121.197/32,Cloudflare
104.28.121.198/32,Cloudflare
104.28.121.199/32,Cloudflare
104.28.121.200/32,Cloudflare
104.28.121.201/32,Cloudflare
2606:54c0:3b00:10::/64,Cloudflare
2606:54c0:3b00:128::/64,Cloudflare
2606:54c0:3b20:10::/64,Cloudflare
2606:54c0:3b20:128::/64,Cloudflare
2606:54c0:3b40:10::/64,Cloudflare
2606:54c0:3b40:128::/64,Cloudflare
2606:54c0:3b60:10::/64,Cloudflare
104.28.0.45/33,Cloudflare
146.75.189.22/31,Fastly
2a04:4e41:0029:000b::/64,Fastly
146.75.196.14/31,Fastly
2a04:4e41:0030:0007::/64,Fastly
146.75.201.12/31,Fastly
2a04:4e41:0035:0006::/64,Fastly

みんなも試してみよう!

原文

Akamai vs Cloudflare vs Fastly : whether Egress Proxy (on Private Relay) are NXDOMAIN

In my country (Tokyo, Japan), it seems like that

"Akamai Technologies, Inc.", "Cloudflare, Inc.", and "Fastly, Inc.",

are providing almost all the egress proxy for using Private Relay (and these are estimated by whois information).

Also I was able to resolve the domain of Akamai's egress proxy with nslookup, such as:

a[ppp-qqq-rrr-sss].deploy.static.akamaitechnologies.com
/* [ppp-qqq-rrr-sss] means available ipv4 address of egress proxy */

but I couldn't resolve Cloudflare's and Fastly's, with NXDOMAIN error.

I want to know how and why has this difference occurred between Cloudflare, Fastly and Akamai, still it's one and the same service.

Because I found that some web services limit the use for accesses from Akami's Egress Proxy only, thus I'm guessing that this difference (security settings?) is causing the inconvenience.

In addition, I would like to know if Apple is comfortable with the situation of egress proxy not in the DNS records. And I hope that near the future either specification would be standardized or users would be able to choose them.


By the way, I might be often assigned the following IP addresses:

the list

172.224.240.128/27,Akamai
172.225.46.64/26,Akamai
172.225.46.208/28,Akamai
172.225.48.0/26,Akamai
172.225.48.128/28,Akamai
172.225.52.176/28,Akamai
172.225.54.128/26,Akamai
172.225.74.128/26,Akamai
172.225.75.0/24,Akamai
172.225.76.0/24,Akamai
172.225.122.0/25,Akamai
172.225.122.192/27,Akamai
172.225.123.0/25,Akamai
172.225.123.128/27,Akamai
172.225.124.192/27,Akamai
172.225.127.0/25,Akamai
172.226.24.0/25,Akamai
172.226.24.128/27,Akamai
172.226.42.0/25,Akamai
172.226.54.0/26,Akamai
172.226.54.64/28,Akamai
172.226.56.0/26,Akamai
172.226.56.64/28,Akamai
172.226.58.0/26,Akamai
172.226.58.64/28,Akamai
2a02:26f7:b980::/42,Akamai
104.28.0.45/32,Cloudflare
104.28.0.46/32,Cloudflare
104.28.4.73/32,Cloudflare
104.28.4.74/32,Cloudflare
104.28.44.37/32,Cloudflare
104.28.44.38/32,Cloudflare
104.28.44.39/32,Cloudflare
104.28.44.40/32,Cloudflare
104.28.44.41/32,Cloudflare
104.28.44.42/32,Cloudflare
104.28.44.43/32,Cloudflare
104.28.44.44/32,Cloudflare
104.28.44.45/32,Cloudflare
104.28.44.46/32,Cloudflare
104.28.44.47/32,Cloudflare
104.28.44.48/32,Cloudflare
104.28.44.49/32,Cloudflare
104.28.44.50/32,Cloudflare
104.28.44.51/32,Cloudflare
104.28.44.52/32,Cloudflare
104.28.44.53/32,Cloudflare
104.28.44.54/32,Cloudflare
104.28.44.55/32,Cloudflare
104.28.44.56/32,Cloudflare
104.28.44.57/32,Cloudflare
104.28.44.58/32,Cloudflare
104.28.44.59/32,Cloudflare
104.28.44.60/32,Cloudflare
104.28.44.61/32,Cloudflare
104.28.44.62/32,Cloudflare
104.28.44.63/32,Cloudflare
104.28.44.64/32,Cloudflare
104.28.44.65/32,Cloudflare
104.28.44.66/32,Cloudflare
104.28.44.67/32,Cloudflare
104.28.44.68/32,Cloudflare
104.28.44.69/32,Cloudflare
104.28.44.70/32,Cloudflare
104.28.44.71/32,Cloudflare
104.28.44.72/32,Cloudflare
104.28.44.73/32,Cloudflare
104.28.44.74/32,Cloudflare
104.28.67.170/32,Cloudflare
104.28.67.171/32,Cloudflare
104.28.67.172/32,Cloudflare
104.28.67.173/32,Cloudflare
104.28.67.174/32,Cloudflare
104.28.67.175/32,Cloudflare
104.28.67.176/32,Cloudflare
104.28.67.177/32,Cloudflare
104.28.67.178/32,Cloudflare
104.28.67.179/32,Cloudflare
104.28.67.180/32,Cloudflare
104.28.67.181/32,Cloudflare
104.28.67.182/32,Cloudflare
104.28.67.183/32,Cloudflare
104.28.67.184/32,Cloudflare
104.28.67.185/32,Cloudflare
104.28.67.186/32,Cloudflare
104.28.67.187/32,Cloudflare
104.28.67.188/32,Cloudflare
104.28.67.189/32,Cloudflare
104.28.67.190/32,Cloudflare
104.28.67.191/32,Cloudflare
104.28.67.192/32,Cloudflare
104.28.67.193/32,Cloudflare
104.28.67.194/32,Cloudflare
104.28.67.195/32,Cloudflare
104.28.67.196/32,Cloudflare
104.28.67.197/32,Cloudflare
104.28.67.198/32,Cloudflare
104.28.67.199/32,Cloudflare
104.28.67.200/32,Cloudflare
104.28.67.201/32,Cloudflare
104.28.67.202/32,Cloudflare
104.28.67.203/32,Cloudflare
104.28.67.204/32,Cloudflare
104.28.67.205/32,Cloudflare
104.28.67.206/32,Cloudflare
104.28.67.207/32,Cloudflare
104.28.70.170/32,Cloudflare
104.28.70.171/32,Cloudflare
104.28.70.172/32,Cloudflare
104.28.70.173/32,Cloudflare
104.28.70.174/32,Cloudflare
104.28.70.175/32,Cloudflare
104.28.70.176/32,Cloudflare
104.28.70.177/32,Cloudflare
104.28.70.178/32,Cloudflare
104.28.70.179/32,Cloudflare
104.28.70.180/32,Cloudflare
104.28.70.181/32,Cloudflare
104.28.70.182/32,Cloudflare
104.28.70.183/32,Cloudflare
104.28.70.184/32,Cloudflare
104.28.70.185/32,Cloudflare
104.28.70.186/32,Cloudflare
104.28.70.187/32,Cloudflare
104.28.70.188/32,Cloudflare
104.28.70.189/32,Cloudflare
104.28.70.190/32,Cloudflare
104.28.70.191/32,Cloudflare
104.28.70.192/32,Cloudflare
104.28.70.193/32,Cloudflare
104.28.70.194/32,Cloudflare
104.28.70.195/32,Cloudflare
104.28.70.196/32,Cloudflare
104.28.70.197/32,Cloudflare
104.28.70.198/32,Cloudflare
104.28.70.199/32,Cloudflare
104.28.70.200/32,Cloudflare
104.28.70.201/32,Cloudflare
104.28.70.202/32,Cloudflare
104.28.70.203/32,Cloudflare
104.28.70.204/32,Cloudflare
104.28.70.205/32,Cloudflare
104.28.70.206/32,Cloudflare
104.28.70.207/32,Cloudflare
104.28.83.195/32,Cloudflare
104.28.83.196/32,Cloudflare
104.28.83.197/32,Cloudflare
104.28.83.198/32,Cloudflare
104.28.83.199/32,Cloudflare
104.28.83.200/32,Cloudflare
104.28.83.201/32,Cloudflare
104.28.83.202/32,Cloudflare
104.28.83.203/32,Cloudflare
104.28.83.204/32,Cloudflare
104.28.83.205/32,Cloudflare
104.28.83.206/32,Cloudflare
104.28.83.207/32,Cloudflare
104.28.83.208/32,Cloudflare
104.28.83.209/32,Cloudflare
104.28.83.210/32,Cloudflare
104.28.83.211/32,Cloudflare
104.28.83.212/32,Cloudflare
104.28.83.213/32,Cloudflare
104.28.83.214/32,Cloudflare
104.28.83.215/32,Cloudflare
104.28.83.216/32,Cloudflare
104.28.83.217/32,Cloudflare
104.28.83.218/32,Cloudflare
104.28.83.219/32,Cloudflare
104.28.83.220/32,Cloudflare
104.28.83.221/32,Cloudflare
104.28.83.222/32,Cloudflare
104.28.83.223/32,Cloudflare
104.28.83.224/32,Cloudflare
104.28.83.225/32,Cloudflare
104.28.83.226/32,Cloudflare
104.28.83.227/32,Cloudflare
104.28.83.228/32,Cloudflare
104.28.83.229/32,Cloudflare
104.28.83.230/32,Cloudflare
104.28.83.231/32,Cloudflare
104.28.83.232/32,Cloudflare
104.28.99.191/32,Cloudflare
104.28.99.192/32,Cloudflare
104.28.99.193/32,Cloudflare
104.28.99.194/32,Cloudflare
104.28.99.195/32,Cloudflare
104.28.99.196/32,Cloudflare
104.28.99.197/32,Cloudflare
104.28.99.198/32,Cloudflare
104.28.99.199/32,Cloudflare
104.28.99.200/32,Cloudflare
104.28.99.201/32,Cloudflare
104.28.99.202/32,Cloudflare
104.28.99.203/32,Cloudflare
104.28.99.204/32,Cloudflare
104.28.99.205/32,Cloudflare
104.28.99.206/32,Cloudflare
104.28.99.207/32,Cloudflare
104.28.99.208/32,Cloudflare
104.28.99.209/32,Cloudflare
104.28.99.210/32,Cloudflare
104.28.99.211/32,Cloudflare
104.28.99.212/32,Cloudflare
104.28.99.213/32,Cloudflare
104.28.99.214/32,Cloudflare
104.28.99.215/32,Cloudflare
104.28.99.216/32,Cloudflare
104.28.99.217/32,Cloudflare
104.28.99.218/32,Cloudflare
104.28.99.219/32,Cloudflare
104.28.99.220/32,Cloudflare
104.28.99.221/32,Cloudflare
104.28.99.222/32,Cloudflare
104.28.99.223/32,Cloudflare
104.28.99.224/32,Cloudflare
104.28.99.225/32,Cloudflare
104.28.99.226/32,Cloudflare
104.28.99.227/32,Cloudflare
104.28.99.228/32,Cloudflare
104.28.101.191/32,Cloudflare
104.28.101.192/32,Cloudflare
104.28.101.193/32,Cloudflare
104.28.101.194/32,Cloudflare
104.28.101.195/32,Cloudflare
104.28.101.196/32,Cloudflare
104.28.101.197/32,Cloudflare
104.28.101.198/32,Cloudflare
104.28.101.199/32,Cloudflare
104.28.101.200/32,Cloudflare
104.28.101.201/32,Cloudflare
104.28.101.202/32,Cloudflare
104.28.101.203/32,Cloudflare
104.28.101.204/32,Cloudflare
104.28.101.205/32,Cloudflare
104.28.101.206/32,Cloudflare
104.28.101.207/32,Cloudflare
104.28.101.208/32,Cloudflare
104.28.101.209/32,Cloudflare
104.28.101.210/32,Cloudflare
104.28.101.211/32,Cloudflare
104.28.101.212/32,Cloudflare
104.28.101.213/32,Cloudflare
104.28.101.214/32,Cloudflare
104.28.101.215/32,Cloudflare
104.28.101.216/32,Cloudflare
104.28.101.217/32,Cloudflare
104.28.101.218/32,Cloudflare
104.28.101.219/32,Cloudflare
104.28.101.220/32,Cloudflare
104.28.101.221/32,Cloudflare
104.28.101.222/32,Cloudflare
104.28.101.223/32,Cloudflare
104.28.101.224/32,Cloudflare
104.28.101.225/32,Cloudflare
104.28.101.226/32,Cloudflare
104.28.101.227/32,Cloudflare
104.28.101.228/32,Cloudflare
104.28.118.164/32,Cloudflare
104.28.118.165/32,Cloudflare
104.28.118.166/32,Cloudflare
104.28.118.167/32,Cloudflare
104.28.118.168/32,Cloudflare
104.28.118.169/32,Cloudflare
104.28.118.170/32,Cloudflare
104.28.118.171/32,Cloudflare
104.28.118.172/32,Cloudflare
104.28.118.173/32,Cloudflare
104.28.118.174/32,Cloudflare
104.28.118.175/32,Cloudflare
104.28.118.176/32,Cloudflare
104.28.118.177/32,Cloudflare
104.28.118.178/32,Cloudflare
104.28.118.179/32,Cloudflare
104.28.118.180/32,Cloudflare
104.28.118.181/32,Cloudflare
104.28.118.182/32,Cloudflare
104.28.118.183/32,Cloudflare
104.28.118.184/32,Cloudflare
104.28.118.185/32,Cloudflare
104.28.118.186/32,Cloudflare
104.28.118.187/32,Cloudflare
104.28.118.188/32,Cloudflare
104.28.118.189/32,Cloudflare
104.28.118.190/32,Cloudflare
104.28.118.191/32,Cloudflare
104.28.118.192/32,Cloudflare
104.28.118.193/32,Cloudflare
104.28.118.194/32,Cloudflare
104.28.118.195/32,Cloudflare
104.28.118.196/32,Cloudflare
104.28.118.197/32,Cloudflare
104.28.118.198/32,Cloudflare
104.28.118.199/32,Cloudflare
104.28.118.200/32,Cloudflare
104.28.118.201/32,Cloudflare
104.28.121.164/32,Cloudflare
104.28.121.165/32,Cloudflare
104.28.121.166/32,Cloudflare
104.28.121.167/32,Cloudflare
104.28.121.168/32,Cloudflare
104.28.121.169/32,Cloudflare
104.28.121.170/32,Cloudflare
104.28.121.171/32,Cloudflare
104.28.121.172/32,Cloudflare
104.28.121.173/32,Cloudflare
104.28.121.174/32,Cloudflare
104.28.121.175/32,Cloudflare
104.28.121.176/32,Cloudflare
104.28.121.177/32,Cloudflare
104.28.121.178/32,Cloudflare
104.28.121.179/32,Cloudflare
104.28.121.180/32,Cloudflare
104.28.121.181/32,Cloudflare
104.28.121.182/32,Cloudflare
104.28.121.183/32,Cloudflare
104.28.121.184/32,Cloudflare
104.28.121.185/32,Cloudflare
104.28.121.186/32,Cloudflare
104.28.121.187/32,Cloudflare
104.28.121.188/32,Cloudflare
104.28.121.189/32,Cloudflare
104.28.121.190/32,Cloudflare
104.28.121.191/32,Cloudflare
104.28.121.192/32,Cloudflare
104.28.121.193/32,Cloudflare
104.28.121.194/32,Cloudflare
104.28.121.195/32,Cloudflare
104.28.121.196/32,Cloudflare
104.28.121.197/32,Cloudflare
104.28.121.198/32,Cloudflare
104.28.121.199/32,Cloudflare
104.28.121.200/32,Cloudflare
104.28.121.201/32,Cloudflare
2606:54c0:3b00:10::/64,Cloudflare
2606:54c0:3b00:128::/64,Cloudflare
2606:54c0:3b20:10::/64,Cloudflare
2606:54c0:3b20:128::/64,Cloudflare
2606:54c0:3b40:10::/64,Cloudflare
2606:54c0:3b40:128::/64,Cloudflare
2606:54c0:3b60:10::/64,Cloudflare
104.28.0.45/33,Cloudflare
146.75.189.22/31,Fastly
2a04:4e41:0029:000b::/64,Fastly
146.75.196.14/31,Fastly
2a04:4e41:0030:0007::/64,Fastly
146.75.201.12/31,Fastly
2a04:4e41:0035:0006::/64,Fastly

so now you can check it.

Firefox Focus + Private Relay + kiriwake jpneの挙動

さて!答えを予想してみましょう!

実験1

iOS 15もしくはiPadOS15において,以下の2つのブラウザ

で以下の3つのサイト

を覗いた時,対応するプロバイダ(もしくはそれが十分に類推できるiPアドレス)が以下のように表示された.

Safari Firefox Forcus
kiriwake jpne cman cgi test-ipv6 kiriwake jpne cman cgi test-ipv6
Private Relay enable v6+ egress node egress node egress node egress node jpne vne jpne vne
4G LTE egress node egress node egress node egress node docomo sp docomo sp
Private Relay disable v6+ jpne vne
4G LTE docomo sp

ここでdocomo sp(spmode-ne.jp)の場合は必ずipv4接続のみが表示された.
egress nodeはiCloud+のPrivate Relay出口ノード(プロキシ?サーバ?)であり,Fastly・Cloudflare・Akamaiのいずれかがランダムに出現する.

考察1-1

AppleによるPrivate Relayの説明によれば,

https://support.apple.com/ja-jp/HT212614

iCloud+ のサブスクリプションで使える iCloud プライベートリレーは、Safari で Web を閲覧する際にプライバシーを守ってくれます。詳しくご説明します。

https://developer.apple.com/jp/support/prepare-your-network-for-icloud-private-relay/

Private Relayは、SafariでのWebブラウジングDNS解決クエリを保護し、Appの安全でないhttpトラフィックからユーザーを守ります。

https://www.apple.com/jp/newsroom/2021/06/apple-advances-its-privacy-leadership-with-ios-15-ipados-15-macos-monterey-and-watchos-8/

Safariでのブラウズ時、Private Relayはユーザーのデバイスから発信されるすべてのトラフィックを確実に暗号化し、Appleやユーザーのネットワークプロバイダを含む何者もユーザーと訪問先のウェブサイトの間でのトラフィックにアクセスしたり読み取ったりできないようにします。

とあることから,Safari(又はSFSafariViewController系)ではないはずのFirefox Focusで接続した時にPrivate RelayのEgress Nodeの情報が表示されたkiriwake jpneの挙動は『この説明には』則していないと言える.

現にこうやって表示された以上,何らかの干渉が発生していると見るべきでは?

考察1-2

kiriwake jpneについて解説するページがjpneからお出しされていたが,

https://www.jpne.co.jp/ebooks/v6plus-ebook.pdf

なお、以下の状況では、JPNE切り分けサイトによるチェックが失敗する可能性があるので注意が必要です。

Google Chromeのライトモード(GoogleのProxy経由)がON Cloudflare WARPなどのVPNが設定されていた

上記状況では、JPNE切り分けサイトとの接続がプロキシ経由になります。このとき、HTTPなどによってJPNE切り分けサイトと直接接続するのはプロキシです。 そのため、JPNE切り分けサイトのチェックで、v6プラスを利用していないと判定されてしまいます。

確かにSafariなら説明の通りの挙動をするものの,Private Relayの対象外であるはずのFirefox Focusでこの挙動をする説明には至っていない.
ページのソースを見ても,jpne.co.jpがこちらのipv6アドレスを把握してページをレンダリングしてから.cgiファイルを送ってきているように見える.

推察1-1

もしかして…ipv6接続が優先されるサイトの場合,ブラウザがSafariかどうかに関わらず,強制的にPrivate Relayを経由するようになる…とか?

追加実験1

nslookup kiriwake.jpne.co.jpで得られた18.179.181.47でアクセスしてみた,これを叩くと強制的にipv4接続になり,トップにipv4アドレスが表示される
…ダメです,出口串のIPアドレスが出ました,結果もさっきと変わらなかったです

追加実験2

iPadOSで同じことをやったらiOSと同じ結果になった,これは一体…

推察1-2

なぜかkiriwake.jpne.co.jpとPrivate RelayとFirefox Focusだけ相性が悪いとしか言いようがない

実験2

他のサイトでもやってみたり,パケット覗き見したりする必要があると思いましたので,お時間を下さい
先駆者の方がいらっしゃいました,これはありがたい…

解決

Appの安全でないhttpトラフィックからユーザーを守ります。

この「App」は「Safariだけ」ではなく「App全体」を指す
すなわち

  • 端末から出るTCP80宛の通信
  • WKWebViewから出るHTTPプロトコルの通信
  • SafariおよびSFSafariViewControllerから出る通信

が対象となり,上記の3サイトの中でkiriwake jpneのURLだけが非httpsであったため,Firefox Focusと言えどPrivate Relayに通信を流さざるを得なかったという流れになる.
もちろん当該サイトはhttpsでも実装されているので,つまり「なぜかhttpでurlを生成して実験した私が悪い」という表現が正確だと言えるだろう.う〜んこの

この辺はOS側で通信をチェックしているようで,非httpsやTCP80宛の通信Chromeでも何でもPrivate Relayへと流される.
実際にやってみたら本当にそうなった,端末から見えちゃうくらいなら隠せというAppleの思想なんだろうか…?

感想

macOS Montereyが正式リリースされ次第でそっちでも確認したいですけど,誰かにこれと同じことを試してみて欲しいと思っています…

実際の検証を元に教えて頂いた方,本当にありがとうございます🙏↓
https://twitter.com/falms

Apple (iCloud+) Private Relay (iOS15~)ついに来たな…

まずは公式サイトを読もう

www.apple.com developer.apple.com support.apple.com

利用環境

iOS 15, iPad OS 15,macOS MontereyにおけるSafari Appおよびアプリ内のSafariブラウザ
(それに加えてSFSafariViewControllerで動いてる画面)

developer.apple.com

他社製のブラウザやWKWebViewで動いている画面,およびその他の通信方法を提供するブラウザは対象外
マシン全体の通信の置き換えはできない,適用範囲はあくまでSafari系の周りにのみ限られる
および,端末から出るTCP80宛の通信WKWebViewから出るHTTPプロトコルの通信全て
(以下に経緯が載っています)

soluna-eureka.hatenablog.com

WKWebViewとSFSafariViewControllerの見分け方

右上か左上に「ぁあ」があればSFSafariViewController,「⬆️」しかなければWKWebView
後者もしくは独自ブラウザで行うTCP443宛のHTTPS通信なら,Private Relayにはまず流されないと考えても良さそう

出口サーバの管轄会社はどこ?

cloudflare, akamai, fastlyの3社…らしい(他にあったら教えて欲しい),ここら辺はAppleが一括で契約してくれてるはず

www.cman.jp

ここから見て自分のIPアドレスの登録情報が上の3社のどれかなら成功

www.speedtest.net

もしくはここで「iCloud Private Relay」と出れば成功

契約方法

既にiCloud+の一部になっているので,iCloudに課金契約をつけてiCloud+にしてやる必要がある, つまりは無料Apple会員では利用不可,当たり前だよなぁ?

support.apple.com

最安なら50GBを130円で購入できるので,ライトユーザーはコレくらいでいいっすね

利用方法

  1. 契約します
  2. 本体を買ってApple IDでログインします
  3. 「設定」→「Apple ID」→「iCloud」→「プライベートリレー(ベータ版)」
  4. オンにする
  5. IPアドレス位置情報設定」をいじる(「国と時間帯を使用」で良いんじゃないかな)

不都合が起きれば別のブラウザを使えば良いしオフにしても良い,割と簡単に素早くスイッチが入るっぽいのでご安心を

仕組み

Private RelayはiCloudに組み込まれている新しいインターネットプライバシーサービスで、ユーザーはより安全でプライバシーが保護された方法でウェブに接続してブラウズできるようになります。Safariでのブラウズ時、Private Relayはユーザーのデバイスから発信されるすべてのトラフィックを確実に暗号化し、Appleやユーザーのネットワークプロバイダを含む何者もユーザーと訪問先のウェブサイトの間でのトラフィックにアクセスしたり読み取ったりできないようにします。その後、ユーザーのリクエストはすべて2つの別々のインターネットリレーを通じて送信されます。1つ目はユーザーの実際の位置情報ではなく地域に割り当てられた匿名のIPアドレスをユーザーに割り当てます。2つ目はユーザーが訪問しようとしているウェブアドレスを復号し、目的の場所にユーザーを転送します。この情報の分割によって、ユーザーと訪問先のサイトの両方が、Appleを含むどのような組織にも特定できなくなるため、ユーザーのプライバシーが保護されます。
https://www.apple.com/jp/newsroom/2021/06/apple-advances-its-privacy-leadership-with-ios-15-ipados-15-macos-monterey-and-watchos-8/

iCloud Private Relayサービスは、革新的なマルチホップアーキテクチャを採用しています。異なる事業者が運用する2つの独立したインターネットリレーを介してリクエストが送信されるため、Appleを含むいかなる者も、ユーザーのブラウジングアクティビティの詳細を閲覧したり収集したりすることができません。Private Relayでは、接続しているクライアントがiPhoneiPadMacであることが検証されるため、接続元のデバイスAppleバイスであることが保証されます。また、デフォルトでは、エグレスIPアドレスにより、クライアントのおおよその位置情報が都市レベルで正確に表されるので、IPアドレスに基づいて地域ベースの制限を課す際に、関連する位置情報をネットワークが受け取ることもできます。その場合でもクライアントの固有IPアドレスは引き続きマスク処理され、匿名化されたアドレスのみがWebサイトと共有されます。これらのアドレスは、Private Relayユーザーのグループ間でも共有されます。
https://developer.apple.com/jp/support/prepare-your-network-for-icloud-private-relay/

通常は、Web を閲覧すると、Web トラフィックに含まれている情報 (DNS レコードや IP アドレスなど) をネットワークのプロバイダや、閲覧した Web サイトに知られてしまいます。この情報を基に、閲覧者の本人確認が実施されたり、時間経過に伴う位置情報や閲覧履歴を蓄積してプロフィールが作成されたりする場合があります。iCloud プライベートリレーは、Safari で Web を閲覧する際に、あなたが誰で、どのサイトを訪れているのか、Apple も含め誰一人としてわからないよう徹底し、プライバシーを守るしくみになっています。
プライベートリレーが有効になっている場合、あなたのリクエストは 2 つの個別の安全なインターネットリレーに分けて送られます。IP アドレスを見ることができるのは、ネットワークのプロバイダと 1 つ目のリレー、これは Apple が運用しています。DNS レコードは暗号化されるので、あなたが閲覧しようとしている Web サイトのアドレスを両者とも知ることはできません。2 つ目のリレーは他社のコンテンツプロバイダが運用していて、これが一時的な IP アドレスを生成し、あなたがリクエストした Web サイトの名前を復号化した上で、そのサイトにあなたをつないでくれます。これらすべてが最新のインターネット標準を用いて行われるので、プライバシーを守りながら、申し分のないパフォーマンスで快適に閲覧を続けられます。
https://support.apple.com/ja-jp/HT212614

1段階目のAppleのサーバはユーザの通信を受け入れ転送する,2段階目のcloudflare, akamai, fastlyのサーバは通信を暗号化し接続する,つまりAppleは通信された内容を把握しないし,依頼された企業は誰が通信したかを把握しないということになる…?
確かにコレなら「通信したユーザと通信した内容を1つの組織が両方を同時に把握する」ことは起こり得ないと言える…

というよりもSafariそのものがTor browserのような機能を持つことになる,どんな回線でもプロバイダの先で最初にicloudに接続し転送と暗号化を経てサイトにアクセスできる,それに加えてそこそこな速度も出るし(最大100Mbpsまでは期待できそう)

IPv6もOK

ちゃんと対応している,フルで置き換わる

QUICとは何か

datatracker.ietf.org tex2e.github.io www.cybertrust.co.jp

難しスギィ!
要するに

  • HTTP-over-QUICがHTTP/3で,
  • QUICはTCPTLS・HTTP/2の一部をUDP上で実装して,
  • IPアドレスが変わっても再接続が可能

ってことですか…?

VPNやTorとは何が違うのか

Safari系の画面でなければ素通りする

TwitterもNicovideoもPixivもJaneStyleもSFSafariViewControllerを使ってなさそうなのでダメっす,必ずSafariからやりましょう

位置情報が探られやすい

IPアドレス位置情報設定」で「おおよその位置情報を保持」にするとGoogleに都市レベルの位置情報をお出しされる
「国と時間帯を使用」でも国家レベルや時間帯レベルの位置情報が出てくる,つまり位置匿名性は高いとは言えない

実際TorやVPNやProxyならば出口ノードが海外におかれる場合も多く,それには劣ってしまう…と考えている(各リレーの中身がどんな風に構成されてるかにもよりそうだが,個人の集合体ではなく4社独占体制なので明らかに脆い)

開示請求するとどうなるの?

実はノーログだったりするのか,それとも素直に応じるタチなのか,Appleと3社の間でどんなやりとりが発生するのか,やってみなければわからない部分が非常に多く,興味深い
というか「プロバイダ←Apple←3社←Web管理者←申立人」みたいな4段構えになりかねない,「お互いにデータを照合しない」というPrivate relayの取り決めがどこまで通用してどこから通用しないのかが,そこが非常に気になる…

林檎ユーザだとバレる可能性がある

段々と出口サーバのIPが割れてくる可能性がある,というよりこの特定作業は有志が既に初めてそうなのが…
https://mask-api.icloud.com/egress-ip-ranges.csv
なんだこれは,たまげたなぁ…(14MBのcsv
既に全てのリレーの出口が掲示されているじゃないか…

中国とロシアで使えない

前者は公式発表だけど後者はユーザー報告,これは法律が違ったり圧力がかかったりしているせいなのですか?

今のところ確認されている不都合

日本国内限定

モバイルデータ通信による機能が阻害される

通信会社からユーザーを見れば,宛先が全てiCloudになる上に内容も秘匿されるので, 一切のコンテンツフィルタは動作しなくなる(本体内蔵型を除く)し, 特定サービスが対象の無料プランも検知できないから適用されない
iOS向けで出しているアプリであっても中身がSFSafariViewControllerで動いているものはアウトになるので, 契約してる携帯通信会社のページを確認しておこう

出口のIPアドレスがコロコロ変わる

特に匿名掲示板だとIDなどが切り変わるか(運がとても良ければ)被る可能性があり,思わぬ事故が発生するかもしれない
不都合なら火狐を使おう

やっぱり遅い

100Mbps以上の速度が欲しかったら使うな

締め出される可能性がある

非常に強い林檎アンチなコミュニティがあれば,IPアドレスから焼かれる可能性が考えられる
もしくは側から見ればプロキシとの違いが明確にわからない以上,トラフィック量だけを見てオートでブロックすれば, 間違ってフィルタされてしまう可能性だってある
後者は簡潔なホワイトリスト設定をAppleが出しているわけではなさそう,さっきのクソデカcsvで何とかするしかないみたい…