Closed13

Remix+CloudflareでWebサイトを作る 29(OTP認証・CacheReverse・PV数の取得調査・debounce・GitHubプロフィール更新・AmazonアフィHTMLを生成)

saneatsusaneatsu

【2024−06-16】Zero TrustでOTP認証

背景

仕様をDocusaurusで書いてCloudflare Pagesで公開した。
これもZeroTrustでOTP認証をかけたいが狙った挙動にするのに少し時間を要してしまったのでメモ。

以前は 【2024-02-08】Cloudlfare Pagesでデプロイして一時的に作成されたURLにアクセス制限をかける でOTP認証をかけた。

やりたいこと

デフォルトでは以下2つのURLが作成されるがどちらにもOTP認証をかけたい。
以前は https://APP_NAME.pages.dev にはかける必要がなかったので前回の設定を参考にしてもうまくいかなかった。

URL 説明
https://RANDOME_ID.APP_NAME.pages.dev Cloudflare では本番・ステージング環境どちらとも、デプロイした際にこの形式で URL が作成される。
https://APP_NAME.pages.dev Cloudflare のアプリケーション名(APP_NAME)から作成された固定のURL

結論

デフォルトではSubdomeinに * が入っているが、これを空白にしないと https://APP_NAME.pages.dev にOTP認証がかからなかった。

あとはAuthenticationタブでOne-time PINを有効化しておけばOK。

その他

古い記事だと Policies タブからポリシーを作成していたりするものもあったがそれは必要なし。

参考

https://efcl.info/2023/07/08/cloudflare-page-zero-trust/

saneatsusaneatsu

【2024-06-22】CloudflareのCache Reverseとは?

背景

サイドバーの「Cache > 概要」を見たら以下のようなページがあった。

・キャッシュ可能なすべてのファイルを Cloudflare の永続オブジェクト ストレージ バケットに自動的に保存することで、キャッシュの有効期間を延長します。
・コンテンツは常にキャッシュから提供されるため、オリジンが不必要なエグレス料金から保護され、レスポンス パフォーマンスが向上します。

ん〜、ふわっとわかった気がしている。つまりわかったようでわかってない。

どこらへんがReverseしてるんだ?
あと「エグレス料金」って何?

なお、新しい知識なのでChatGPTは知らなかった。

調べる

オリジンの負荷を軽減、クラウドのエグレス料金を節約、Cache Reserveでキャッシュヒット率を最大化

Cache Reserveは、R2の永続的なデータストレージを使用することにより、ユーザーがCloudflareのキャッシュからコンテンツをより長く提供できるように支援します。Cloudflareのキャッシュからコンテンツを提供することは、オリジンからのエグレス料金の請求を減らすことでWebサイト運営者に利益をもたらし、同時にコンテンツの読み込みを速くすることで、Webサイトの訪問者にも利益をもたらします。

最初に言っていた「 Cloudflare の永続オブジェクト ストレージ バケットに自動的に保存」というのはR2に保存することを指しているのか。

キャッシングとは?| Webサイトはどのようにキャッシュされるか? | Cloudflare

つまり、普通のキャッシュはCDNサーバーにキャッシュしてるけど、R2も使うことでより長くキャッシュしたコンテンツをユーザーに返せるようになったということか。

また出てきた「エグレス料金」とはなんぞや。

エグレス料金

エグレスとは? 意味や使い方 - コトバンク

通信回線の方向性を表す概念の一。ある機器やシステム、ファイアウォール内のネットワークから外に向かう通信のこと

ふむ。
そのうえで定義は?

データエグレス料金とは | Cloudflare

データエグレス料金とは、データをアップロードしたクラウドストレージからデータを移動・転送する際に、クラウドプロバイダーから請求される料金です。この料金は、帯域幅料やデータ通信料とも呼ばれ、企業がクラウドストレージやコンピューティングに支払う料金とは別のものです。

オリジンサーバーからユーザーへの距離がめっちゃ離れてるとこのエグレス料金がかかるけど、キャッシュを効率的に使えればオリジンサーバーへのアクセス減るしエグレス料金も少なくなって嬉しいね、という話。

で、この図の上位層(オリジンサーバーに近い)と下位層(ユーザーに近い)は階層型キャッシュといい、下位層にキャッシュがない場合、オリジンにいく前に上位層をチェックするというもの。
データを階層化するとユーザー、リクエスト、オリジンの間に複数のキャッシュを置くため、より長い時間適切な場所にコンテンツをキャッシュできるようになる

既存のキャッシュの問題点

結論

キャッシュミス発生
→何度もオリジンからコンテンツを取ってくるはめに
→エグレス料金上昇

キャッシュミス

以下のような場合にキャッシュミスというものが起きるらしい。

  • cache-control時間を設定して、コンテンツが古くなり(stale)、 revalidated (再検証) を必要とする場合
  • キャッシュを破棄する場合
    • CDNはキャッシュがいっぱいになった時にコンテンツを破棄する
    • これは「least recently used(使用される頻度が最も低いもの)」(LRU)というアルゴリズムによって決定する
    • 「cache-control」によってコンテンツが何日もキャッシュされるように指定されていても、より人気のあるコンテンツをキャッシュするために、(そのキャッシュ内で最もリクエストされなかった場合)より早くそれを立ち退かせる必要がある場合がある

今までは長期間リクエストされていないコンテンツ(コールドコンテンツ)は退避させ、再度リクエストがきたらオリジンから提供していた。
ネット上で何が人気かがわかればアセットをキャッシュすることができるが、常に変化するため難しい。

そしてコールドコンテンツを退避させるとオリジンから何度も引っ張り出してくる必要があるのでエグレス料金が増えてくる。

つまり、キャッシュミスが起こるとネットワークの運用が非効率になり
→オリジンへのアクセスが増え
→エグレス料金増加

そこで、Cache Reverse

とは

キャッシュから退避させられるコンテンツのデータセンターで、キャッシュ可能なすべてのコンテンツをバックアップするセーフティネット。

キャッシュの流れ

  1. 退避させられそうになるとCache Reverseという名のデータセンターに入る
  2. 一度入るとデフォルトで30日ここに保存される
  3. 30日の間にまたリクエストが来たら更に30日延長

つまり、cache-controlがそのコンテンツをキャッシュから提供すべきでないと示すまで延長する。

ヒット率が向上してオリジンからのエグレス料金も減少して嬉しい!
ということらしいが、、、特別なことやってるんじゃなくてシンプルにR2使ってキャッシュできる量増やしただけでは??

他にもエグレス料金抑えられる設計が

オリジンの負荷を軽減、クラウドのエグレス料金を節約、Cache Reserveでキャッシュヒット率を最大化

この製品には、さらなるエグレスのコスト削減が組み込まれています。
例えば、欠落していた場合、オブジェクトがCache Reserveに書き込まれます。つまり、キャッシュから欠落しオリジンからコンテンツを取得する際、リクエストに応答するためにそれを使うと同時に、Cache Reserveにアセットを書き込むため、お客様はそのアセットをエグレスから長時間提供することがないのです。

Cache Reserveは、階層型キャッシュを有効にして使用することで、オリジンシールドを最大化するように設計されています。下位層と上位層の両方でキャッシュミスがあると、Cache Reserve がチェックされ、ヒットした場合、オリジンがリクエストを確認したり追加データを提供する必要がなく、レスポンスは訪問者に返される途中で、下位層と上位層の両方でキャッシュされるのです。

その他

CloucdflareのTiered Cache(階層型キャッシュ)を有効にしてキャッシュヒット率を高めてみた | DevelopersIO

この記事では、ヨーロッパから日本、日本から日本へアクセスしてみて実際にレスポンスを測定してみていて面白かった。

実測値見るの大事。

saneatsusaneatsu

【2024−06-22】PV数を取得したい

Web Analyticsはデフォルトで入っているのを使っているけど、Cloudflareの管理画面で見るのではなく、開発しているWebサイトでPV数とか表示したい。

やり方全くわからず。

調べる。

1. httpRequestsAdaptiveGroups

Can I get path/views from Analytics API? - Developers / API - Cloudflare Community

そういや、Remix+CloudflareでWebサイトを作る 4 > 【2024-02-11】GrapiQL使おうとした(使えなかった) で調べたけどGrapiQL使ってなんやかんやだったか。

で、確か httpRequestsAdaptiveGroups を使うにはProにする必要があった気が...。

2. rumPageloadEventsAdaptiveGroups

Downloading Web Analytics data - General / Analytics - Cloudflare Community

3. Enterpriseだけ?

How to get Web Traffic data from GraphQL API? - General / Analytics - Cloudflare Community

2022-09ではEnterpriseだけが利用できたっぽい。

結論

  • Freeプランじゃ無理そう(最新情報じゃないから自信ないけど)
  • GraphQLで頑張る必要がありそう(使ったことない)

ちょっと大変そう。
しらんけど。

saneatsusaneatsu

【2024−06-22】Markdown編集時にdebounceを使ってTwitter埋め込みを随時行う

背景

1個前の 28 > 【2024−06-08】Twitterの埋め込み対応しようとしたらクロスドメイン設定によるFaild to fetch発生 でTwitterの埋め込み対応をサーバー側で行った。

単純にMarkdownをHTMLにして表示するだけのページだったらサーバーで1回だけ取得を行えばいいんだけど、逐次Markdown→HTMLへの変換を行う必要がある新規作成や編集のページでは今のコードに素直にTwitterの埋め込み処理を追加すると文字を入力・削除した回数分呼び出されてしまう。

なんだかスマートではない気がする。
debounceを使って数msに1回発火させる的なことをしたい。

実装

lodashのdebounceを使う

ざっくりこんな感じ。

pnpm add @types/lodash
import debounce from "lodash/debounce";

const debouncedSave = useCallback(
  // debounceは最後の呼び出しから一定時間が経過した後にのみ関数を実行する
  debounce(
    (markdown: string) => {
      // 1. ここでfetcher.submitをしてTwitterの埋め込み用HTMLを取得
      const newMarkdown = ''

      // 2. HTMLを描画
      updateHtml(newMarkdown);
    },
    // この秒数ごとにMarkdownをHTMLに変換して画面に表示する
    500,
    {
      // 500ms の間に入力し続けている場合はcallbackの処理を行わない
      // つまり、入力し続けているとHTMLに変換されないので入力画面に文字は入力され続けるがプレビュー画面に何も表示されない
      // maxWaitを指定することで最低でも 1000ms 毎にcallbackを実行してくれる
      maxWait: 1000,
    }
  ),
  [] 
);

debounceを使って実装しながら思ったけど本当はTwitterのリンクが変わった時しか埋め込み用HTML取得するloader(action)は叩かなくていいんだよなぁ...。

ZennはPreviewボタンで明示的に切り替わるからここらへん考えなくてもいいよな。

案1:前回変更との差分取ってTwitterのリンクの差があれば埋め込みHTMLを取得する
案2:TwitterのURLって高頻度で変えるものでもないし、もういっそボタン設置しておいてクリックしたときだけ埋め込みHTMLを取得する

Markdownの差分を取る

https://stackoverflow.com/questions/8024102/compare-strings-and-get-end-difference

これだと最後尾に文字列を追加した場合にしか対応できない。

Doing...

saneatsusaneatsu

【2024−07-01】Amazonアフィ用に商品名・商品サムネイル・価格を含んだHTMLをブックマークレットを使って作成する

背景

MarkdownにAmazonのアフィリンクを入れたい。

しかし、Amazonのアフィリンクを生成に関して以前はHTMLを生成できたようだが現在はできなくなり、短縮URL or 通常URLしか手に入れられない。

どうしようか?
ユーザー的に一番楽なのは「短縮URLを入れたらバックエンドでそのURLにアクセスして、画像URLや、商品名、価格を取得してHTMLを返す」かな〜とも思いつつ実装も簡単にしたい。

https://zenn.dev/zenncev549fhhx/articles/4593792c5d0024

探してみるとこんな記事があった。
なるほど。ブックマークレットを使う方法があるのか。
実装したことないしこれを機にやってみよう。

ちなみに

色々調べてみると認証キーを使って、ItemIdsなるものを入力して...というやり方が出てくる。
めんどそうなのでやりたくない。

方針

  1. Amazonの商品ページにアクセス
  2. アソシエイトツールバーを表示
  3. ブクマしてあるボタンを押す
  4. クリップボードにAmazonの商品URL、商品画像、商品名、価格が入ってあるHTMLを取得する(スタイルの指定は別途CSSで行う)

コード書いてみる

ダブルスラッシュ(//)でコメントを書くとJSの変換サイトによってはうまくいかないことがあるので注意。
「うまくいかない」場合ブクマのボタンを押したらConsoleでエラーが出ている。

https://ytyng.github.io/bookmarklet-script-compress/ を使ってJSを変換した。

/**
 * 変換先: https://ytyng.github.io/bookmarklet-script-compress
 * 注意事項:
 * ダブルスラッシュでコメントを埋め込んで実行するとブックマークレットを実行した際に、
 * エラーを起こしてJavascriptをクリップボードにコピーしてしまう
 */
javascript: (function () {    
  /* 商品名 */
  const name = document.title;

  /* リンク */
  const associateEl = document.getElementById(
    "amzn-ss-text-shortlink-textarea"
  );
  let affiliateLink;
  if (associateEl) {
    /* 短縮URLを取得する */
    /* そのためにはアソシエイトツールバーで「リンクを生成」を押して、テキストリンクを生成した状態になっている必要がある */
    affiliateLink = associateEl.value;
  } else {
    affiliateLink = location.href;
  }

  /* 商品サムネイル */
  const imgTagWrapper = document.getElementById("imgTagWrapperId");
  const imgTag = imgTagWrapper.getElementsByTagName("img")[0];
  const thumbnailSrc = imgTag.getAttribute("src");

  /**
   * 価格
   * 右の表示されている一番上の価格を取得する
   * 「定期おトク便」があればそれを取得する
   */
  const priceEl = document.getElementById("apex_offerDisplay_desktop");
  const priceHtmlCollection = priceEl.getElementsByClassName("a-price-whole");
  const price = priceHtmlCollection[0].textContent;

  /* HTMLを生成してクリップボードにコピー */
  const el = document.createElement("input");
  el.value = `
<div class="ad-amazon">
  <a rel="noreferrer noopener nofollow" href="${affiliateLink}" target="_blank">
    <div class="product-thumbnail">
      <img src="${thumbnailSrc}" alt="${name}" />
    </div>
    <div class="product-info">
      <p class="price">${price} 円</p>
      <p class="name">${name}</p>
      <button>Amazon</button>
    </div>
  </a>
</div>`;
  document.querySelector("body").append(el);
  el.select();
  document.execCommand("copy");
  el.remove();
})();
saneatsusaneatsu

これ、HTMLしか生成しない場合後から見た目をアップデートした場合後方互換性取れないのでは。
こんな感じで大幅に見た目変えたくなるかもしれないし。


ref: https://sigezo.xsrv.jp/amazon-foundation

class="ad-amazon" というクラス名をclass="ad-amazon-v2" みたいに更新していけばどうにかはなるけどそれは流石にキモい...。

そう考えるとCSSを付与した状態で生成する方が良い気がしてきたぞ。

saneatsusaneatsu

pタグやpreタグを使っているとTailwindのスタイルが付与できないけどdivならうまくいく。
なんでだろう。

理由はわからんが全部divを使ってTailwindを直接付与する方法なら簡単にできそう。

<!-- うまくいかない -->
<p class="text-red-300">${name}</p>

<!-- うまくいく -->
<div class="text-red-300">${name}</div>
saneatsusaneatsu

Javascriptの変換先によって癖がある

Bookmarkleter

ここを使っている。
コメントを消してくれるので/* */ではなく // でコメントを書いても大丈夫になった。

bookmarklet maker

class名の間の空白スペースが勝手に削除されることがある

<!-- hover の前の半角スペースが削除されているのでスタイルが反映されない -->
<div class="w-full bg-[#fdaf17]hover:bg-[#fdaf17]/90"></div>

Bookmarklet スクリプト変換

HTMLをコピーする時に半角スペースが入る時がある

<div class="max-w-48">
  <a rel="noreferrer noopener nofollow"href="https://amzn.to/3W9XPx8"target="_blank">		
    ...
  </a>
 </div> <!-- 先頭に半角スペースが入ってしまう -->
saneatsusaneatsu

商品名を以下で取ると Amazon | <商品名> になる。

const name = document.title;

Amazon | が邪魔なのでページから取得する。

const name = document.getElementById("productTitle").textContent.trim();
saneatsusaneatsu

アソシエイトツールバーを開いていない時に location.href でリンク取得するようにしているけど、href="" という感じで空になっている

目に見えていなくてもページに要素自体は存在するのが原因っぽいので修正した。

ただし、一度アソシエイトツールバーを開いて短縮リンクを取得できた場合、その後はアソシエイトツールバーを開かずとも短縮リンクを取得できるようになってしまう。

  // 商品リンク
  const shortlinkEl = document.getElementById(
    "amzn-ss-text-shortlink-textarea"
  );
  let link;
  if (shortlinkEl) {
    // 短縮URLを取得する
    // ブックマークレット実行時にアソシエイトツールバーの「テキスト」を押してポップオーバーを表示している必要がある
    link = shortlinkEl.value;
  }

  if (!link) {
    alert(
      "アソシエイトツールバーから「リンク生成 > テキスト」をクリックしてポップオーバーを表示してください"
    );
    return;
  }
saneatsusaneatsu

ということで完全版。

/**
 * 変換先: https://chriszarate.github.io/bookmarkleter/
 */
javascript: (function () {
  // 商品名
  const name = document.getElementById("productTitle").textContent.trim();

  // 商品リンク
  const shortlinkEl = document.getElementById(
    "amzn-ss-text-shortlink-textarea"
  );
  let link;
  if (shortlinkEl) {
    // 短縮URLを取得する
    // ブックマークレット実行時にアソシエイトツールバーの「テキスト」を押してポップオーバーを表示している必要がある
    link = shortlinkEl.value;
  }

  if (!link) {
    alert(
      "アソシエイトツールバーから「リンク生成 > テキスト」をクリックしてポップオーバーを表示してください"
    );
    return;
  }

  // 商品サムネイル
  const imgTagWrapper = document.getElementById("imgTagWrapperId");
  const imgTag = imgTagWrapper.getElementsByTagName("img")[0];
  const thumbnailSrc = imgTag.getAttribute("src");

  // 価格
  // 右の表示されている一番上の価格を取得する。「定期おトク便」があればそっちを取得する。
  const priceEl = document.getElementById("apex_offerDisplay_desktop");
  const priceHtmlCollection = priceEl.getElementsByClassName("a-price-whole");
  const price = priceHtmlCollection[0].textContent;

  // HTMLを生成してクリップボードにコピー
  const shadcnButtonClasses =
    "inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 rounded-xl";
  const el = document.createElement("textarea");
  el.textContent = `
<div class="max-w-48">\n
\t<a rel="noreferrer noopener nofollow" href="${link}" target="_blank">\n
\t\t<div class="grid justify-items-center items-center h-52 bg-white">\n
\t\t\t<img src="${thumbnailSrc}" alt="${name}" />\n
\t\t</div>\n
\t\t<div class="space-y-1">\n
\t\t\t<div class="text-sm text-right text-foreground">${price} 円</div>\n
\t\t\t<div class="text-sm line-clamp-3 text-foreground">${name}</div>\n
\t\t\t<div class="w-full bg-[#fdaf17] hover:bg-[#fdaf17]/90 text-slate-950 ${shadcnButtonClasses}">\n
\t\t\t\t<button>Amazon</button>\n
\t\t\t</div>\n
\t\t</div>\n
\t</a>\n
</div>`;
  document.querySelector("body").append(el);
  el.select();
  document.execCommand("copy");
  el.remove();
})();
このスクラップは6ヶ月前にクローズされました