技術メモ

プログラミングとか電子工作とか

新幹線EX予約、ブックマークレットで領収書の宛名入力を自動化

背景

EX予約、かなり便利で嬉しいのですがデフォルトで宛名を入力してくれてたりしないので領収書を生成する際にいちいち入力するのがダルい。
こういうのはブックマークレットを利用すれば楽になります。

ブックマークレットとは

ブックマークレットは、ブラウザのブックマーク(お気に入り)のところに保存できる小さな JavaScript プログラムです。一般的なブックマークがウェブサイトを開くのに対し、ブックマークレットはクリックすると特定の動作を実行します。
今回の様なフォームの自動入力のような時につかうもの、と覚えておくと、今後便利に利用出来ると思います。
比較的簡単なので、たぶんChatGPTのような生成AIで精度良く生成出来ると思います。

領収書宛名入力+印刷ボタンを押下を自動化するブックマークレット

ワンクリックで入力+印刷ボタンを押下 という処理をブックマークレットで実装します。
内容に興味が無い人用にまずは結論から書きます。

javascript:(function(){document.getElementsByName('i1')[0].value='【宛名上段:ここを変更】';document.getElementsByName('i2')[0].value='【宛名下段:ここを変更】';document.getElementsByName('b1')[0].click();})();

上記の【宛名上段:ここを変更】【宛名下段:ここを変更】を変更すればそのまま利用できます。
Chromeの場合はツールバーの"★Bookmarks"から

右上のアイコンをクリックし

"新しいブックマークを追加"を押し、

ブックマークを追加の、名前には適当に分かりやすいものを入れて、URLに先ほどのブックマークレットを入力します。
これを、EX予約の領収書の宛名入力画面で利用すると、宛名が自動で入力され、印刷画面に移動します。

この画面で、先ほどのブックマークレットをお気に入りから選択して実行する

仕組み

非常に単純です。ブックマークレットは表示している画面でJavaScriptを実行するのと同じなので、即時実行関数を定義して実行します。
document.getElementsByNameでフォームの要素を拾ってきてその値を書き換え、同様にボタンの要素をgetElementsByNameで拾ってきてclick()メソッドでクリック動作を実行します。
敢えてインライン表記ではなく通常のJavaScriptと同様に書くと下記の通りです。

javascript:(function() {
  // フォームの内容を変更
  document.getElementsByName('i1')[0].value = '【宛名上段:ここを変更】';
  document.getElementsByName('i2')[0].value = '【宛名下段:ここを変更】';
  
  // 送信ボタンをクリック
  document.getElementsByName('b1')[0].click();
})();

getElementsByNameでフォームを拾ってきているので、現在のHTMLから変わったら使えなくなります。

Remix入門を一通りやった感想的なアレ

基本的にはNext.jsを学習してきましたが、13あたりのアップデートからややついて行けない感が出て新規に採用するべきか悩んでいました。
とりあえずことある毎に公式のチュートリアルは全て見てはいるんですが、きっちり理解できている感じがしなくて「なんとなく」使ってる感が強いのです。
(React Server Componentsの理解もイマイチです)
また、Amplifyへの対応もある程度はされていますがVercelにロックインされていってるような仕様にもやや抵抗感がないとも言えません。

そこで、下記のスライドをちら見したときにまさしく自分に当てはまることかな?と思いました。

こういうときは素直にオススメを学んでみるべきだと考えていますので、Remixあたりを学習してみます。

他人が読んで分かりやすいような入門記事では無いと思います、申し訳御座いません。

セットアップ

テンプレートから生成する場合は--templateオプションをつけてnpxで生成するようです。

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

ディレクトリ名などはCUIのウィザードで指定できるので適当に指定します。
とりあえずnpm run devlocalhostに接続出来れば問題無いと思います。

Root Route

設定次第で変える事はできそうですが、デフォルトではroot.tsxがいわゆるレイアウトとしてレンダリングされ、子要素は別途ルーティングの結果がレンダリングされるようです。
root.tsxexport defaultに指定している関数がレイアウトとしてレンダリングされています。
MetaLinksといったコンポーネントが入っていますが、そのうち説明があると想定してとりあえず無視して進めます。

スタイルシートの設定

スタイルシートの設定の一例として、JavaScript modulesとしてインポートする方法が記載されています。

import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

NextJSなどと比べるとやや異なりますが、許容範囲内でしょう。
別のコンポーネントなどでも思ったのですが、Remixは利用するコンポーネントに対して特定の関数名でexport constしておけば設定が適応されるような設計になっていそうです。

ルーティング

レイアウトはapp/route.tsxでしたが、肝心のページはapp/routes/にファイルを追加していけばルーティングされてレンダリングしてくれるようです。
チュートリアルではlocalhost:3000/contacts/123のようなファイルをapp/routes/contacts.$contactId.tsxのように指定するようです。
ファイル名に.を指定すると、ルーティングでは/と同義になるようで、$contactIdのように$マークを利用するとレンダリングする際のパラメータとして受けれるようです。 (どうも、routesディレクトリ以下にディレクトリを配置しないような思想?まだイマイチつかめてません。)

root.tsx<Outlet/>に、該当するファイルがレンダリングされて挿入されます。
いわゆるindexに関してはIndex Routesという項目があるので後々語られるのでしょう。

クライアントサイドでレンダリングするようなリンクは、Next.js同様Linkというコンポーネントで作成するようです。

import {
  Link
} from "@remix-run/react";

データのロード

データのフェッチなどはuseLoaderDataという関数を利用していくようです。

import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

~

import { getContacts } from "./data";

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData();

getContactsは下記の様に定義されています。

export async function getContacts(query?: string | null) {
  await new Promise((resolve) => setTimeout(resolve, 500));
  let contacts = await fakeContacts.getAll();
  if (query) {
    contacts = matchSorter(contacts, query, {
      keys: ["first", "last"],
    });
  }
  return contacts.sort(sortBy("last", "createdAt"));
}

fakeContactsdata.ts内で定義される仮想データベースを模擬したオブジェクトとい言えるでしょう。data.ts内でデータがハードコードされています。
実際にはloader内で外部にfetchするなどの処理を書くのだと思います。

型を適応するにはuseLoaderDataloaderの型を適応させます。

const {contact} = useLoaderData<typeof loader>()

この辺はシンプルで良い感じだと思います。

ローダーでのURLパラメータ

ルーティングの箇所で少し言及しましたが、app/routes/contacts.$contactId.tsx$contactId部分は、loarderでパラメータとして利用できるようです。

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

ここでは型の適応とバリデーションがなされていないのですが、Remixに含まれる型とtiny-invariantを利用して下記の様に書けるそうです。

import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

~

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

invariantはファイル名とコード間でパラメータ名が間違えてないか確認してくれる関数のようです。基本的には書きましょうという感じでしょうか。
throw new Response周りもnullな際に404をレンダリングしてくれるそうです。この辺はloaderの基本フォーマットみたいな感じで使えそうです。

データ変更

データの新規作成

Remixではクライアントサイドのルーティングを使用してaction関数にフォームのデータを送信するそうです。
とりあえず例を見ていきます。

import { createEmptyContact, getContacts } from "./data";

export const action = async () => {
  const contact = await createEmptyContact();
  return json({ contact });
};

通常のReactアプリケーションであれば、useStateuseEffectなどで変更を察知して必要な箇所を再レンダリングするする必要がありそうですが、Remixの場合はactionに書けばエエ感じに処理されるみたいです。基本的には全ての操作がフォームとして処理されるんでしょうか?

データの更新

ここで編集用のページを作成していきます。
app/routes/contacts.$contactId_.edit.tsxというファイル名で作成するようですが、どうもapp/routes/contacts.$contactId.edit.tsx.として作成するとapp/routes/contacts.$contactId.tsx.の内部にルーティングの箇所で出てきた<Outlet/>レンダリングされるようなものになるようです。この辺はやや命名規則がヤヤコシイ気がします。
ファイル内容はチュートリアルの内容を参照して下さい。

actionが書かれていないので、その当たりを追記していきます。

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updateContact(params.contactId, updates);
  return redirect(`/contacts/${params.contactId}`);
};

フォームデータのシリアライズは実際に作成する際には色々気をつける箇所もあるかもしれませんが、非常にシンプルに書けていると思います。
上記のformDataformData.get([フォームの名称])でデータにアクセスできるそうで、

        <input
          defaultValue={contact.first}
          aria-label="First name"
          name="first"
          type="text"
          placeholder="First"
        />

のように書かれているフォームのnameがフォームの名称にあたります。
とはいえ今回はフォームの名称をupdateContactに必要な名称に合わせてあるため、Object.fromEntries(formData)で全部拾ってきて渡している実装になっています。

root.tsxactionではloaderと同じjson({contact})を返していましたが、ここではリダイレクト処理が含まれるレスポンスを返すそうです。

また、NavLinkを利用すると、toのプロパティに指定されているURLをレンダリングしてる際に適応されるclassなど適応できるようです。
この辺は嬉しい仕様。

Global Pending UI

Remixでは次のページがロード完了するまで古いページを表示しているような仕様になってるそうで。
遷移時の挙動をカスタマイズするにはuseNavigationを利用してRemixの状態を利用します。

const navigation = useNavigation()

navigationidleloadingsubmittingの3種を返してくれるそうです。

データの削除

これはサンプルのコードに既に記載済みでした。

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >

どうやら、Formタグにactionという属性を付与すると、[自分のファイル名].destroy.tsxのように属性名のファイルに記載されたファイルを参照し、そこのaction関数を実行するようです。
詳しい挙動はチュートリアルに記載がありますが、Submitボタンが押された後に

  1. は、ブラウザのデフォルトの動作であるサーバーへの新しいドキュメントの POST リクエストを防ぎ、代わりにクライアントサイドのルーティングと fetch を使ってリクエストをエミュレートする。
  2. は "contacts.$contactId.destroy" の新しいルートにマッチし、リクエストを送信する。
  3. action でリダイレクトした後、Remixは画面上のデータの loader をすべて呼び出して最新の値を取得する。 useLoaderData は新しい値を返し、コンポーネントを更新させる。

という処理が成されるようです。

Index Route

Root Routeの箇所でもふれましたが、いわゆるindex.tsxにあたるものです。
app/routes/_index.tsx が該当するようですが、名称には_が必要なようです。やや特殊なルーティングのせいでしょうか?

Cancel Button

データの変更の箇所にある「データの更新」で作成したapp/routes/contacts.$contactId_.edit.tsx にあるCancelと記載されたボタンの実装です。

// existing imports
import {
  Form,
  useLoaderData,
  useNavigate,
} from "@remix-run/react";
// existing imports & exports

export default function EditContact() {
  const { contact } = useLoaderData<typeof loader>();
  const navigate = useNavigate();

  return (
    <Form key={contact.id} id="contact-form" method="post">
      {/* existing elements */}
      <p>
        <button type="submit">Save</button>
        <button onClick={() => navigate(-1)} type="button">
          Cancel
        </button>
      </p>
    </Form>
  );
}

useNavigateというhookを利用して実装しています。非常にシンプルな実装で利用しやすい工夫が成されているようです。

URLSearchParams と GET サブミッション

サイドバーにある検索ボックスの実装です。
今まではフォームの機能としてactionを利用していましたが、検索ボックスの挙動としては、URLの変更が、下記の様なクエリの変更となるケースが考えられます。

http://localhost:5173/?q=ryan

<Form method="post">はデータの追加や更新に利用されるアクションなので、そうでない場合の実装を考えて行くようです。
まずはクエリを含むURLがリクエストされた際の実装をloaderにしていきます。

import type {
  LinksFunction,
  LoaderFunctionArgs,
} from "@remix-run/node";

// existing imports & exports

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts });
};

// existing code

この変更をすることで、フォームに文字を入力してEnterをおすか、クエリを含むURLにアクセスすればサイドバーがフィルタリングされて表示されます。

URLの状態とフォームの状態の同期

現在の実装だと下記の二点の問題があります。

  • 検索後に戻るボタンをクリックすると、リストがフィルタリングされていないのにフォームフィールドにまだ入力した値が残っている。
  • 検索後にページを更新すると、リストはフィルタリングされているのにフォームフィールドの値がなくなっている。

上記はクエリが適応されたURL(http://localhost:5173/?q=ryanのようなURL)とフォームフィールド(検索ボックス)の状態が同期していないことによります。
これに対し、まずは下記から対処していきます。

~

export const loader = async ({
  request,
}: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return json({ contacts, q });
};

~

  const { contacts, q } = useLoaderData<typeof loader>();

~

            <Form id="search-form" role="search">
              <input
                aria-label="Search contacts"
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              {/* existing elements */}
            </Form>

これでクエリが適応されたURLを開いた際にはloaderからqが渡され、フォームフィールドに適切なテキストが入力されている状態になるはずです。

次に1個目の問題に対応していきます。 ReactのuseEffectを利用して挙動を実装するようです。

// existing imports
import { useEffect } from "react";

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();

  useEffect(() => {
    const searchField = document.getElementById("q");
    if (searchField instanceof HTMLInputElement) {
      searchField.value = q || "";
    }
  }, [q]);

  // existing code
}

useStateなどを利用して状態管理する方法もあるようですが、現状だと上記のような実装がシンプルということでしょうか。
この辺は適宜使い分けるのがよさそうです。

FormのonChangeによる送信

フォームの内容が変更されればURLが更新されるようにするには、useSubmitを利用して実装するようです。
hookで簡単に実装できるのでええかんじです。

検索時のSpinnerの追加

このデモではわざと遅延を設定しているようですが、本番環境でもレスポンス待ちなどほとんどのケースで遅延は考えられます。
その際のスピナー(いわゆるぐるぐる)をつけてUXをマシにしようというヤツです。
root.tsxで検索中を検知する変数を定義します。

// existing imports & exports

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const navigation = useNavigation();
  const submit = useSubmit();
  const searching =
    navigation.location &&
    new URLSearchParams(navigation.location.search).has(
      "q"
    );

  // existing code
}

何も待ちなどがない場合はnavigation.locationundefinedだそうですが、遷移中はデータが格納されるそうです。
navigation.location.searchを見てSpinnerを実装していきます。

            <Form
              id="search-form"
              onChange={(event) =>
                submit(event.currentTarget)
              }
              role="search"
            >
              <input
                aria-label="Search contacts"
                className={searching ? "loading" : ""}
                defaultValue={q || ""}
                id="q"
                name="q"
                placeholder="Search"
                type="search"
              />
              <div
                aria-hidden
                hidden={!searching}
                id="search-spinner"
              />
            </Form>

検索中かどうかはSpinnerで実装されたので、<Outlet/>がフェードアウトしないように変更します。

      <div
        className={
          navigation.state === "loading" && !searching ? "loading" : ""
        }
        id="detail"
      >

履歴スタックの管理

検索文字を変更する度にURL遷移が発生し、履歴スタックが無限に増えて行きます。
これを避ける為に現在のエントリーを変更する処理をroot.tsxに実装していきます。

// existing imports & exports

export default function App() {
  // existing code

  return (
    <html lang="en">
      {/* existing elements */}
      <body>
        <div id="sidebar">
          {/* existing elements */}
          <div>
            <Form
              id="search-form"
              onChange={(event) => {
                const isFirstSearch = q === null;
                submit(event.currentTarget, {
                  replace: !isFirstSearch,
                });
              }}
              role="search"
            >
              {/* existing elements */}
            </Form>
            {/* existing elements */}
          </div>
          {/* existing elements */}
        </div>
        {/* existing elements */}
      </body>
    </html>
  );
}

useSubmitで生成したsubmitの第二引数のオブジェクトにreplaceというキーとともにbooleanを渡せば履歴スタックの変更か、履歴スタックに追加かが選べるようです。

URL変更なしのフォーム

上記まではフォームの変更や送信でURLが変更されていましたが、変更せずにフォーム送信したいケースもあるはずです。
チュートリアルの場合ではお気に入りボタン?である★マークの挙動などが該当します。
この辺を実装するのにuseFetecherを利用するようです。
app/routes/contacts.$contactId.tsxに変更を加えていきます。

// existing imports
import {
  Form,
  useFetcher,
  useLoaderData,
} from "@remix-run/react";
// existing imports & exports

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
};

上記の実装でFavoriteのFormがactionを呼び出します。
なので、actionを実装していきます。

import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from "@remix-run/node";
// existing imports

import { getContact, updateContact } from "../data";
// existing imports

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true",
  });
};

// existing code

Formで呼び出されたactionのような挙動が実装できました。
ちゃんとサイドバーと表示されている人物の名前の横にある星の両方が変更されています。

Optimistic UI

上記のお気に入りボタンの反映にも遅延が入っているようです。
実際の挙動もここまでではなさそうですが、サーバーとの通信がある以上遅延はあるでしょう。
この遅延に対してもなにがしかのUIの実装が望ましいですが、Spinnerのところではnavigationを利用していました。今回はURLの遷移がないのでこれは利用できません。
そこでfetcher.stateで実装できるようなのですが、ここではOptimistic Updates(楽観的更新)を採用して実装してみようという試みのようです。

// existing code

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const fetcher = useFetcher();
  const favorite = fetcher.formData
    ? fetcher.formData.get("favorite") === "true"
    : contact.favorite;

  return (
    <fetcher.Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "★" : "☆"}
      </button>
    </fetcher.Form>
  );
};

fetcherやらnavigationを採用していれば、Optimistic UIは自動的に適応されるようです。

チュートリアルを通してのかんそう

ややクセはありそうですが、慣れれば非常にシンプルでかつ効果的な実装になっているのではないでしょうか。
こまかい制御をやろうとすると原始的な実装やドキュメントを読み込む必要が出てくるかもしれませんが、学習コストとしては思ったより低いのではないでしょうか。

2021年買って良かったモノ13選

こういうポストをちらほら見かけるので自分もまとめてみます。毎年恒例にできればいいな。
なんやかんやでガジェットみたいなモノと酒しかねぇなという感じではあるんですが・・・

もの

YubiKey 5 NFC

2段階認証しておくのが当たり前になってきた時代ですが、いちいち二次元バーコード読んだりするのもめんどくさいし復元が謎のコードというのもしんどいなぁと思っていたので導入。
NFCも着いてるのでiPhoneの二段階認証もこれで管理できる。セキュリティ意識を高めたい人にはお奨めしたいデバイス
SSHのキーも内部に格納できるので、サーバー周りとAWSのアカウントをまとめて権限を委譲するとかも物理的にできるのでかなり便利です。

Easthills Outdoor(イーストヒルズ) 保冷リュック

これ次の酒カテゴリに入れるか迷いましたが一応汎用性もあるのでこちらで。
なんとこいつは一升瓶が入るのでちょっとした外飲み(夏のBBQなど)に一升瓶を保冷して持って行ける優れもの。
とはいえ今在庫が無いので同じ様なの買うならこちら?

Easthills Outdoors バックパック クーラー 32缶

ヘラマンタイトン ヘララップ

結束バンド「インシュロック」を作っているヘラマンタイトンが出してる電線などをまとめる保護チューブ。スパイラルチューブ自体はよく見ると思いますが、このヘララップはまとめる作業が圧倒的に楽になります。
φ5 ~ 30mmまでいろんな種類があるので適宜選んでもらえたら。自分は用途的に8mmを選択しましたが、PC周りの整理とかに使いたい場合は16mmのものがいいかも。
より詳細が知りたい場合はヘラマンタイトンのウェブサイトを確認して下さい。

www.hellermanntyton.co.jp

Elgato Stream Deck

自分が買ったのは一世代前ですが、妙に値上がりしてるので現行モデルを載せてます。
ストリーマー用のガジェットなんですが、普通に左手デバイスとして便利です。
青軸キーボードの左手デバイスを使っていましたが、アプリケーションによって配置を覚えるのが大変なんです。このデバイスはボタンがディスプレイになっているので、ボタンの役割が一目見ただけで分かるのがとても便利です。
更に、機能によっては動的な表示(例えばタイマーだとカウントダウンの数値がでる)もできるのでそういうところも便利。
Toggl Trackなどタイムトラッキングアプリを利用している人には超お薦めデバイスです。

ソニー 完全ワイヤレスイヤホン WF-SP900

ここ数年ぐらいは風呂でシャワーを浴びている時ですら音を聞いているのでIP68ぐらいの防水機能は欲しいところ。(スマホのスピーカーではシャワー時に音が聞けません)
最初は昔使っていたAftershokzのTitaniumを使っていた(とはいえコレはIP55なのでお風呂での使用は推奨しません)のですが、折れて故障するトラブルになってしまい別のモノを探すことに・・・
実は3000円~5000円ぐらいのデバイスを5つぐらい試したのですが、全て一ヶ月前後で故障しました。まぁシャワーまで耐えられるデバイスの方が本来不思議なんですが。
このデバイスを購入して2ヶ月ほどなので、ここまで故障しなかったデバイスはコレが初めてです。
とはいえ説明書にも「シャワーなどの利用は想定していません」と書かれているので一応非推奨です。ご注意を。

ソニー ワイヤレスノイズキャンセリングイヤホン WF-1000XM4

今まではWF-1000XM3を利用していてそこまでバージョンアップする必要性もないと思っていたんですが、コンプライ Ts-200を愛用しているとランニングコストがまぁまぁかかることに気づき、それなら新しいやつ買うかぁと思って買いました。
ノイズキャンセリングのレベルも上がっていますが、個人的に嬉しかったのが風切り音の低減(WF-1000XM3は少し風が吹くと鼓膜がなくなる)と外音取り込みがよりクリアに聞こえる様になったと感じてます。
あとはイヤーピースの劣化度合いがどんなもんか検証するのみです。

サンワダイレクト キーボードスライダー

基本はキーボードが目の前にあれば嬉しいのですが、何か書きながらキーボードを利用したいときなどはいくら机が広くてもどちらかを奥に追いやるワケにも行きません。
そういうときに便利なのがこのキーボードスライダー。机の下にキーボードをおいておけば、キーボードで作業しながらノートを書くということも出来ます。
机の広さに応じて大きさを選ぶと良いと思いますが、マウスもおきたいならMかLをオススメします。

個人的に買って良かった酒関連です。飲んで良かったというよりは1本買って良かったという意味合いが深いかも。
色々な要素を鑑みて絞りました。

グレンケアン ブレンダーズ モルトグラス 6個セット

いきなり酒ではないんですがやはり飲み比べするときに並べて飲み比べたいもの。
モルトグラスを必要最低限のクオリティで個数を揃えるならグレンケアン一択です。蒸留所などに行く機会がある人はおそらく蒸留所で買うのが安いしロゴが入っててオシャレなのでそこで買うのがオススメです。
(蒸留所では大体一脚500~800円ぐらいで売ってると思います)
有り難いことにモルト飲み比べをしてくれる友人がいるので今年買い足しました。

Douglas Laing XOP Miltonduff 25 yo [1994]

f:id:ysmn_deus:20211230152206j:plain

ダグラスレインのXOPシリーズのミルトンダフ。最高にうまい。蜂蜜系のあま~いウイスキーが好きなら絶対好き。
長熟にしかない複雑みも感じられるのに比較的低価格なのはとても良いリリース。

Bruichladdich The Ternary Project

f:id:ysmn_deus:20211230152244j:plain

国内で輸入してるお店はあまり見たことないですが直販から買えます
いわゆるクラシックラディとポートシャーロットとオクトモアのバッティングモルト。混ぜれば良いってもんじゃねぇぞ!と怒りたくもなる内容ですがおそらく飲めばその真価を感じられるかと。
ポートシャーロットとオクトモアの占める割合が高いので、それはもう煙くさいウイスキーなんですが麦芽の甘みや乳酸感とのバランスもしっかり取れているし余韻もかなり続くドチャクソ満足度の高い一本です。

ノブクリーク 9年 シングルバレル・リザーブ

今年買ったコスパ部門1位だと思うバーボン。60度を超しててシングルバレルで5000円以下なら文句の言い様はないでしょう。
やはりバーボンはハイプルーフバチバチのパンチ感にバニラと樽感が乗ってきたらそれはもう美味しい!
あんまりコスパコスパ言いたくはないですが、地域毎にコスパ良いものを考えて行くのも楽しそうです。

【九尾】なすひかり48% 生酒

f:id:ysmn_deus:20211230165524j:plain

個人的に勝手に推してる天鷹酒造さんの【九尾】シリーズで通年販売になったリリース。
香りや旨みなどのバランスが好きで、挑戦的なシリーズなのに良心的な価格設定なので是非いろんな人に飲んでいただきたいと思えるし、自分でもリピートしたいと思える日本酒です。

WAKAZE THE BARREL LIMITED -CRONOS-

f:id:ysmn_deus:20211230145254j:plain

限定ボトルで今は買えないんですが、もしかしたらそのうち買えるようになるかもしれないので。
貴醸酒の様な造りの原酒をコニャック樽で熟成させた日本酒で、香り味わいのほとんどが洋酒!って感じのフルーティーで甘いのですが、飲むと奥に日本酒を感じるとても面白いリリースでした。
同じ様なリンク8888も去年飲んで美味しかったのですが、今年もこんな完成度が高く面白味もあるSAKEに出会えて幸せでした。

ほんとはもっといっぱい紹介したいお酒はありますが、まぁ厳選ということでこの辺で。

Raspberry Pi Zero Wのセットアップ

防備録も兼ねてRaspberry Pi Zero Wのセットアップ方法をかいておきます。
後で気づいたんですが多分IPの固定もheadless setupが可能だと思います。(時間があればどっかでチャレンジします)

必要なモノ

細々したのがめんどくさいという人はスターターキットを買ってしまうのも手でしょう。

amzn.to

セットアップ

OSのインストール

Raspberry Pi Imagerのセットアップ

今はRaspberry Pi Imagerという便利ツールがあるのでソレを利用すると簡単にSDカードのセットアップが出来ます。
Raspberry Piのサイトから該当のインストーラーをダウンロードしてインストールして下さい。

f:id:ysmn_deus:20210415142740p:plain

f:id:ysmn_deus:20210415142931p:plain
起動するとこんなかんじ

SDカードのセットアップ

Raspberry Pi Zeroは基本GUIは使わないと思うのでLite版をインストールします。Operating Systemの項目はPi OS LITEを選択します。

f:id:ysmn_deus:20210415143209p:plain f:id:ysmn_deus:20210415143218p:plain

Storageは各々のSDカードを指定してください。

上記が完了すればWRITEというボタンが押せると思うので書き込み開始してください。

f:id:ysmn_deus:20210415143407p:plain

f:id:ysmn_deus:20210415143502p:plain
SDの中身消えるけどほんまにええんやな?という確認

暫く待つと完了すると思います。

無線LANのセットアップ

画面とキーボードを接続してセットアップしても良いんですが、基本的にminiHDMIやmicroUSB A→USB Aみたいな気持ち悪い変換を普通は持っていないと思います。
なので画面など無しでセットアップ(headlessみたいなワードで検索すると色々出てくると思います)します。

SSHの有効化

公式ドキュメントに従い、SDカードのルートディレクトリ(一番上)に'ssh'という空ファイルを作ります。
どんな方法でも良いですが、Windowsならファイルパスを利用するバーを利用するのが手軽でいいです。
SDカードのディレクトリ(自分の場合はH: でした)にエクスプローラーで移動して、パスのバーに

cmd /C copy nul ssh

と入力してEnterを押すと、開いているディレクトリにsshという名前の空ファイルが作成されます。

f:id:ysmn_deus:20210416183337p:plain

ファイルが出来てればOKです。

WiFiの設定

公式ドキュメントに従いますが、bootフォルダを作成してくれと書かれていますがたぶんSD名がbootなのでこれは無視してカード直下にwpa_supplicant.confというファイルを作成します。このファイルに設定を記載します。

ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=1
country=JP

network={
 ssid="<WiFiのSSID>"
 psk="<WiFiのパスワード>"
}

上記で設定は完了です。
電源を入れてしばらく待ちます(たぶん5分以内ぐらいだったと思います。)

初期設定

SSHでログイン

初期設定を行う為、SSHでログインします。
上記までのセットアップでssh pi@raspberrypi.localでログインできる筈です。
デフォルトのパスワードはraspberryです。

raspi-config

基本的な初期設定はsudo raspi-configで行えるようになっています。

f:id:ysmn_deus:20210417184216p:plain

raspi-configでは

を調整します。

パスワード変更

raspi-configの画面は矢印キーとEnterで操作できますが、一番上の1 System OptionsでEnterを押します。
その次にS3 PasswordをEnterで選択します。

f:id:ysmn_deus:20210417184644p:plain

ウィザードに従ってパスワードを設定します。

f:id:ysmn_deus:20210417184738p:plain
二度パスワードを入力させられる

これで次回以降ログイン時にはパスワードが今決めたものになります。
あとでSSHの公開鍵認証に変更しようと思うので、ログインで使う事は無いかもしれませんが念のため変更しておくことをお薦めします。

地域情報の設定

sudo raspi-configで対話式の設定項目を出した後に5 Localisation Optionsに入ります。

f:id:ysmn_deus:20210420172128p:plain

次にL1 Localeを選択します。

f:id:ysmn_deus:20210420172933p:plain

そうするとConfigureing localesという画面に切り替わります。
キー操作で上下に移動出来ると思います。デフォルトではen_GB.UTF-8 UTF-8が選択されていますが、一般的なen_US.UTF-8 UTF-8も追加しておきます。
日本語が使いたい人はja_JP.UTF-8 UTF-8も選択しても良いかもしれません。

f:id:ysmn_deus:20210420173038p:plain

default localeをen_US.UTF-8 UTF-8にして完了

f:id:ysmn_deus:20210420173824p:plain

次にタイムゾーンを変更します。
先ほどの5 Localisation Optionsに入ったあとで次はL2 Timezoneを選択します。

f:id:ysmn_deus:20210420173834p:plain

自分は日本に合わせたいのでAsiaを選択します。

f:id:ysmn_deus:20210420173841p:plain

町の一覧が出てくるのでTokyoを選択します。

f:id:ysmn_deus:20210420173845p:plain

これで完了です。一応時間がちゃんと設定されているか確認するにはdateコマンドなどを使ってみるのも良いでしょう。

IPの固定

今はDHCPでIPが降ってきてるだけなので固定しておきます。
公式サイトに従い、/etc/dhcpcd.confの最後に追記します。

interface wlan0
static ip_address=192.168.0.4/24    
static routers=192.168.0.254
static domain_name_servers=192.168.0.254

(この辺の設定は適宜変えて下さい。)

IPは適当なモノか、思いつかなければ降ってきてるIPを固定するのでも良いでしょう。
自分はip addr showコマンドで確認してそれを利用しました。

WiFiの追加設定

一応WiFiのパスワードを平文で入力しているのでハッシュ化しておきます。
wpa_passphraseコマンドで必要情報が出力できるので、コレを利用します。

sudo sh -c 'wpa_passphrase "SSHD名" "パスワード" >> /etc/wpa_supplicant/wpa_supplicant.conf'

以前のWiFiの設定やパスワードなどが記載された状態なので、適宜/etc/wpa_supplicant/wpa_supplicant.confを編集して下さい。
たぶん

network={
 ssid="<WiFiのSSID>"
 psk="<WiFiのパスワード>"
}
network={
 ssid="<WiFiのSSID>"
 #psk="<WiFiのパスワード>"
 psk=ハッシュ値
}

みたいになってるので、

network={
 ssid="<WiFiのSSID>"
 psk=ハッシュ値
}

にしてあげて下さい。

公開鍵認証の設定

ここは通常のLinuxと変わらないと思いますので割愛します。

パッケージなどの更新

この辺まできたら大体完了なのでパッケージの更新などをしておきます。

sudo apt update && sudo apt upgrade -y
sudo apt dist-upgrade

速度を気にされる方はリポジトリを日本にしてから更新した方が早いと思います。
pipのインストールあたりでたぶん悪さするので推奨しません。

sudo sh -c 'mv /etc/apt/sources.list /etc/apt/sources_old.list; echo "deb http://ftp.jaist.ac.jp/raspbian stretch main contrib non-free rpi" > /etc/apt/sources.list'

create-react-appで作成したプロジェクトのテスト

f:id:ysmn_deus:20210329174147p:plain React周辺は周りの誰にも聞けないし本当に手探りで色々やってます。
いいかげんフロントでもテストを導入しないと意味不明になってきたので、まずはReact自体のテストを学習します。
基本的にはJest公式などを参考にしながら進めています。

環境構築

プロジェクトの作成

この辺はすっ飛ばしても良さそうですが、今回は学習ということで新規にプロジェクトを作成していきます。
いつもはNext.jsを利用していますが、極力シンプルに進めたいので通常のReactプロジェクトを作成していきます。

npx create-react-app react-test-learning --template typescript

とりあえず実行しておきます

cd react-test-learning
yarn start

いつものくるくるReactマークが出ればOKです。

ロジックのテスト

簡単なテスト

まずはReactのテストというよりJavaScriptのテストを行ってみます。
srcディレクトリにsum.tssum.test.tsを追加します。

sum.ts

export const sum = (a: number, b: number): number => {
  return a + b
}

sum.test.ts

import {sum} from "./sum"

test("add 1 + 2 to equal 3", () => {
  expect(sum(1,2)).toBe(3)
})

これでとりあえずテストは回る筈です。
yarn testでJestを実行します。

 PASS  src/sum.test.ts
  √ add 1 + 2 to equal 3 (2 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.888 s, estimated 4 s
Ran all test suites related to changed files.

とりあえず良さそうです。
クリーンなプロジェクトだとTypeScriptを利用しているのでBabelの設定などいるんでしょうが、create-react-appを利用してプロジェクトを作成しているのでその辺はよしなにしてくれているっぽいです。

ちゃんとしたテスト入門であればexpectMatcherを色々見ていくところですが、その辺はやりながら覚えることにします。
基本に立ち返る際にはJestの公式を参照したいところです。

jestjs.io

非同期のテスト

非同期のコードが含まれているテストにはテストに渡す関数の引数に与えられるdoneを使うかPromiseで処理するかのどちらかだそうです。
おそらくナウいのはPromiseだと思いますし、今は async/await を多用すると思うのでその辺で試してみます。
めんどくさいので先ほどのsum.tsに追記します。

sum.ts

~

export const promiseFunction = (value: number) => {
  return new Promise((resolve)=>{
    setTimeout(() => {
      resolve(value)
    }, 1000)
  })
}

sum.test.ts

import {sum, promiseFunction} from "./sum"

~

test("async test", async () => {
  const value = await promiseFunction(100)
  expect(value).toBe(100)
})

Jestを実行します。

 PASS  src/sum.test.ts
  √ add 1 + 2 to equal 3 (1 ms)
  √ async test (1000 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.8 s
Ran all test suites related to changed files.

ちゃんとasyncの方はsetTimeoutで設定した1000msかかってます。

describeブロックとかスコープの話

簡単なテストであれば上記までのtestで処理出来そうですが、もう少し大がかりなテストになってくるとブロック化してテストを回したくなるはずです。(例えば、どこかに通信して取得したデータに対して、それぞれ処理を行うモジュールなど)
テストファイルのグローバルにbeforeEachbeforeAllなどを記載することも出来ますが、describeブロックに分けて実行する方がシンプルだと思います。
セットアップとティアダウンに関しては公式の情報が参考になると思います。

セットアップと破棄 · Jest

testとit

ウェブ上ではitを利用しているテストもありましたが、testはitのエイリアスなので全く同じモノのようです。
ただ公式ドキュメントでもitを利用してテストを書いているケースはないので可読性?からかtestを利用する方がよさそうです。

Reactのテスト

スナップショットテスト

JavaScript(TypeScript)のテストは上記までのもので大丈夫そうではある(モックとかあるけどその辺は後回し)ので、Reactのテストを行っていきます。
まずはレンダリングがうまくいってるか分かりやすいスナップショットテストを行ってみます。
create-react-appでプロジェクトを作成していると、App.tsxApp.test.tsxが自動で作られているのでコレを使い回します。

テストするコードはJestの公式を参考に、react-test-rendererを使用しない形でスナップショットを取っています。

App.tsx

import React, {useState} from 'react';
import logo from './logo.svg';
import './App.css';

type Status = "normal" | "hovered"

const App = () => {
  const [status, setStatus] = useState<Status>("normal")

  const onMouseEnter = () => {
    setStatus("hovered")
  }

  const onMouseLeave = () => {
    setStatus("normal")
  }

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          className={status}
          href="https://reactjs.org"
          onMouseEnter={onMouseEnter}
          onMouseLeave={onMouseLeave}
        >
          Learn React
        </a>
      </header>
    </div>
  )
}

export default App

App.test.tsx

import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import App from './App'

test('changes the class when hovered', () => {
  const testComponent = render(<App />)
  expect(testComponent).toMatchSnapshot()
  fireEvent.mouseEnter(screen.getByRole("link"))
  expect(testComponent).toMatchSnapshot()
});

Jestを実行すると、srcディレクトリ下に__snapshots__ディレクトリが作成され、スナップショットが保存されていると思います。

とはいえ、この辺は個人的にはStorybookのVisual Testingで良い気がするのでたぶん使わないと思います。

レンダリングテスト

データなどを受信したり非同期処理を行った後に正しくレンダリングされているかテストします。
とはいえこの辺は実際に作って見て考える要素が大きいので、ここではTesting Libraryの公式例を挙げておくにとどめておきます。

testing-library.com

基本的にはロジックのテストと同様に行いますが、下記に注意しながらテストを組み立てていくようです。

  • 検証したい要素をTesting LibraryのscreenのメソッドのgetByTextgetByRoleなどで取得して評価する
  • イベントの発火はTesting LibraryのfireEventを利用して発火させる
  • イベントの発火の影響を待つ場合はTesting LibraryのwaitForを利用してawaitで待つ

という感じのようです。
もしテストに慣れて記事に出来る程度に知識がついたら別の記事にするかも知れません。

Next.js+TypeScript+CSS Modulesの環境でStorybookを使う

去年の10月に同じような記事を書いた気がするんですが、もううまく動かなくなってました。
元々フロントは専門外なのでちょくちょくしか触らないのですが、なかなかに辛い・・・
と思ってたのも1日。やはり世界には優秀な方々がたくさんいるようで、既に解決策を提示して下さっております。

Configure Storybook to work with Next.js, TypeScript, and CSS Modules · GitHub

main.jswebpackFinal/\.module\.css$/CSS Modulesのファイル)をstyle-loader+css-loaderで読み込ませようという処理を試みている記事はウェブ上にいくらか見つけていたんですが、何故かうまくいってませんでした。
この記事では一旦普通のCSSファイルをエスケープするために

~
    newConfig.module.rules.find(
      rule => rule.test.toString() === '/\\.css$/'
    ).exclude = /\.module\.css$/;
~

という処理を挟んでいるので、コレが効いてくるのでしょう。
おそらくStorybookのCSSをWebpackのどこかの処理で読み込んでいるのがCSS Modulesと競合してうにゃうにゃなっているんでしょうか。フロントエンド何も分からないマンとしては意味不明です。

上記のgistとその参照元を参考に、CSS Modulesだけ適応したいので、.storybook内にあるファイルは下記のように変更しました。
一応style-loaderとcss-loaderが入ってない場合は入れといて下さい。

yarn add -D style-loader css-loader

まずはmain.js

const path = require('path')

module.exports = {
  stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
  presets: [path.resolve(__dirname, './next-preset.js')],
}

次にnext-preset.jsを作成して、下記の通りにしました。

module.exports = {
  webpackFinal: async (config) => {
    const { module = {} } = config

    const newConfig = {
      ...config,
      module: {
        ...module,
        rules: [...(module.rules || [])],
      },
    }

    newConfig.module.rules.find(
      (rule) => rule.test.toString() === '/\\.css$/'
    ).exclude = /\.module\.css$/

    newConfig.module.rules.push({
      test: /\.module\.css$/,
      use: [
        'style-loader',
        {
          loader: 'css-loader',
          options: {
            importLoaders: 1,
            modules: true,
          },
        },
      ],
    })

    return newConfig
  },
}

preview.jsはとりあえずそのままです。グローバルでCSSを読み込ませたい場合はここで読み込ませるみたいです。
一応例を挙げるなら

import '../src/styles.css';

とか。

YouTubeのSuperChatをGoogle Spredsheetに読み込む(SuperChatEvents: list使用編)

YouTube Live Streaming APIYouTube Data API v3)を利用してスーパーチャット情報をGoogle Spredsheetに出力してみました。
が、結論から言えば実用性が無かったので供養エントリです。なんでダメだったか知りたい人など対象です。
(あとOAuthの勉強の題材探してる人とか)

内容を要約すれば

  • SuperChatEvents: listを利用してGoogle Spredsheetにデータを抽出する方針、実際にココまではできた
  • 上記APIOAuth認証で承認したアカウントのスーパーチャットを抽出できる(第三者のスーパーチャットは取得できない)
  • 最新50件のみ取得可 ← コレが問題

という感じです。
実用的なのは検証中(クォータ割り当て上限の引き上げは現在やりとり中・・・)

前提

HTMLのパースをしないワケ

結構スーパーチャットを抽出するツールは有志の方々によって開発されています。
ただし、大体YouTubeのチャット欄のHTMLをパースして抽出する方針のものが散見されます。
(これはおそらくなのですが、どっかのバージョンアップまでチャット情報を取得するAPIが無かったのにも起因している可能性はあります)
僕個人としてはYouTube側がしっかりしたツールを用意していない方が問題だと思っているのでいちいち指摘したくないんですが、これはYouTubeの規約に抵触する可能性があります。

利用規約に以下の行為は禁止するという下記のような記載があります。

本サービスの利用には制限があり、以下の行為が禁止されています。
[省略]
3. 自動化された手段(ロボット、ボットネット、スクレーパなど)を使用して本サービスにアクセスすること。ただし、(a)公開されている検索エンジンYouTuberobots.txt ファイルに従って使用する場合、または(b)YouTube が事前に書面で許可している場合を除きます。

www.youtube.com

じゃあ実行タイミングが手動ならええんか?という規約の穴を突くような発想もしたくなりますが、意図としては「負荷増えるからAPI以外でのスクレイピングすんな」だと思うので

の3択が考えられます。
2番目は過去に盥回しにされた人を観測していた経験があり、3番目はアレなので1番目の方針で考えましょう。

なんでスプレッドシート+JS?

非エンジニアの方でも比較的使いやすいスプレッドーシートの拡張機能として実装した方が役に立つかなぁと思った次第です。
TypeScriptに脳を侵された人間なので純JavaScriptみたいな言語は非常に辛いのですが、この規模ならどんな言語でもヨシとします。

実装

流れ

  1. OAuth認証の前準備をしておく(一番最初に書くけど別タイミングにする)
  2. スプレッドシートを作成する
  3. スクリプトを追加する
  4. スクリプトを書く
  5. OAuth認証を行う
  6. 実行する

OAuth認証の前準備をしておく

基本的にこの辺を参照して下さい。もしかしたら古くなってるかも。

www.tech-note.info

ただし、上記の準備では不十分です。不十分な部分は後ほど解説します。

スプレッドシートを作成する

とりあえずスプレッドシートを作成しないとなにも始まらないので作成します。
スプレッドシートのコンソールから新規作成します。

https://docs.google.com/spreadsheets/?usp=mkt_sheets

とりあえず「無題のスプレッドシート」という箇所に何かファイル名を入れておいて保存しておきます。
名前を適当に入れたら勝手に保存されます。

f:id:ysmn_deus:20201019115600p:plain

f:id:ysmn_deus:20201019115904p:plain

スクリプトを追加する

「ツール」→「スクリプトエディタ」を選択するとスプレッドシートに紐付いたスクリプトが作成されます。

f:id:ysmn_deus:20201019120201p:plain

先ほどと同様に適当に名前は付けておきましょう。

スクリプトを書く

main.gstoken.gsの二種類に分けてますが、1ファイルでも別に動くと思います。

f:id:ysmn_deus:20201019130233p:plain

ファイルを追加するには、左上の「ファイル」→「New」→「スクリプトファイル」で名前を適当に入れます。
main.gsを追加したい場合は「Enter new file name」と書かれた箇所にmainと入れると作成されます。

main.gs

token.gs

OAuth認証を行う

OAuth認証の準備をする(続編)

OAuth認証の前準備をしておくの手順では不十分な箇所をここに来てようやくすすめて行きます。
token.gsにはdoGetという関数が含まれているのですが、これはOAuth認証のリダイレクトで飛ばされた先のウェブアプリケーションになっています。
('ω')。o(????????????)という人は、とりあえず無視して読み進めて下さい。たぶん分かります

OAuth認証を承認するには

  1. Googleの承認画面にアクセスする
  2. 自分の作ったスクリプトGoogleアカウントの機能を利用する(今回はYouTube Data API v3の読み取りのみ)
  3. 戻ってきてアクセストークンを取得する為のいろんなidとかを取得する

という流れがあります。

トークン情報をメモっておくシートを作成する

スプレッドシートトークン情報を保存しておくシートを作成します。
まだ何も書いてない「シート1」の名前を変更するか、新しくシートを作るかして、「token」というシートを作成してください。
そこに、OAuth認証の前準備をしておくで取得した「クライアントID」と「クライアントシークレット」を保存しておきます。

f:id:ysmn_deus:20201019141904p:plain

分かる方は適当な位置で大丈夫ですが、よく分からない人は画像の通りA1~A8に名称(無くても良いんですが分かりやすいので)、B1~B8に該当する情報を入力していきます。
今回はB1に「クライアントID」を、B2に「クライアントシークレット」を入力して下さい。

Googleの承認画面にアクセスする

本来であればこの辺はWebアプリケーションに組み込んで運用するものだと思いますが、いかんせんめんどくさ過ぎるのでURLを生成する関数を実行してURLを取得します。
そしてコレをブラウザにコピペして承認してしまおうという魂胆です。

token.gsを開き、メニューから「公開」→「ウェブアプリケーションとして導入」を選択します。

f:id:ysmn_deus:20201019140050p:plain

すると「Deploy as web app」という表示がでてくるので、特に何も考えずに下にある「Deploy」というボタンを押します。

f:id:ysmn_deus:20201019140234p:plain

初めて実行する際には「このスクリプトスプレッドシートの編集や外部サービスへのアクセス(YouTubeからデータを取ってくる操作)」を許可する必要があります。
一度許可していれば後からは何も言われませんが、上記の「Deploy」を押すと下記の警告が表示されるかと思います。

f:id:ysmn_deus:20201019140455p:plain

「許可を確認」を押します。
自分のアカウントを選択して次に進みます。

f:id:ysmn_deus:20201019174837p:plain

(ここでアプリ名が「test01」になってますが、これはスクリプトの名前(エディタが表示されてるところの左上にあるやつ)です。)

f:id:ysmn_deus:20201019140837p:plain

「このアプリは確認されていません」とヤバそうな表示が出ますがヤバくないです。自分の作ったばっかりのアプリケーションなのでGoogle様による確認が済んでないのは当然です。
進む為に左下の詳細をクリックして進みます。

f:id:ysmn_deus:20201019140847p:plain

f:id:ysmn_deus:20201019141047p:plain

順調にいけば最後にURLが表示されます。このURLをtoken.gsの13行目にあるdeployURLとして保存します。

f:id:ysmn_deus:20201019141217p:plain

例えばURLがhttps://script.google.com/macros/s/hogefuga/execと表示されていれば、token.gsの13行目を下記のように修正します。

const deployURL = "https://script.google.com/macros/s/hogefuga/exec"

ここまで来たら、ツールバーの「関数を選択」という箇所で「makeAccessTokenURL」を選択し、三角アイコンのボタンを押します。

f:id:ysmn_deus:20201019142255p:plain

問題無く実行できれば、準備の段階に作成した「token」というシートの一番下にURLが追記されています。

f:id:ysmn_deus:20201019142409p:plain

早々にアクセスしたくなるのですが今アクセスするとはじかれます。
OAuth認証の前準備をしておくでOAuth画面の作成など行ったと思いますが、同じ画面で先ほどのdeployURLへのリダイレクトを許可します。

f:id:ysmn_deus:20201019142703p:plain

このクライアント名のところをクリックすると変種画面に移動します。

f:id:ysmn_deus:20201019142810p:plain

「承認済みの JavaScript 生成元」という箇所にhttps://script.google.comを、「承認済みのリダイレクト URI」にdeployURLに設定したURLを入力します。
画像では2個URLが入ってますが、1個です。
「保存」を押して完了です。

OAuth認証を行う(トークンを取得する為のcodeの取得)

ここまで来たら、先ほど「makeAccessTokenURL」で作成したURLにアクセスします。

f:id:ysmn_deus:20201019142409p:plain

アクセスすると、先ほど「ウェブアプリケーションとして導入」の時に見たような画面が表示されます。

f:id:ysmn_deus:20201019164028p:plain
国会の黒塗り資料みたいになってるのは許して

OAuthクライアント作成時の名前が表示されており、その下にYouTubeのチャンネル(アカウント)が表示されていると思います。
該当するアカウント(スーパーチャット情報が取得したいアカウント)をクリックして次に進みます。

また見覚えのある画面だと思いますので、同様に処理していきます。

f:id:ysmn_deus:20201019164317p:plain

f:id:ysmn_deus:20201019164359p:plain

ここの承認の際に「YouTubeアカウントの表示」となっている事を確認して下さい。
僕が悪意のある人間で、なにがしか余計なことをするのであれば情報の表示のみならずコンテンツの編集やアカウントの情報変更など全てできる権限を承認させます。
ここでは「表示」とあるので、最悪情報が取られるだけで済みます。それはそれで困りますが、ソースも全部出してるのでどういう処理してるのかは確認して下さい。

f:id:ysmn_deus:20201019164715p:plain

しつこいぐらい承認のステップがありますが、OAuthは簡単にできる反面結構権限が強いです。
このぐらい「ほんまに大丈夫なんか?」と聞かれる行為であるということを肝に据えて開発をしましょう。(自戒を込めて)
「許可」を押して処理完了です。

{"status":"ok"}

と表示されれば処理完了です。

上記の処理の最後でエラーページが表示される

幾つかアカウントを所持してログインしていると、上記の処理の最後でエラーになる事があります。
その場合はそのブラウザを閉じずに、エラーページが表示されているブラウザのURLを確認して下さい。
おそらくパラメータにcodeというものが含まれていると思います。

https://script.google.com/macros/s/hogefuga/exec?code=XXXXXXXXXXXXXXXX&hoge=YYYYYYYYYYYYYYYYYY...

上記のcode=XXXXXXXXXXXXXXXXにあたるXXXXXXXXXXXXXXXXの情報さえあれば問題無いのでもしエラーが表示されてしまった場合はそちらをコピーして下さい。
そして、スプレッドシートで作成した「token」というシートのB3にあたるcodeという箇所に貼り付けて置いて下さい。

OAuth認証を行う(新規トークンの発行)

上記までうまくいっていればあとはそこまで難しい処理では無い筈です。
token.gsgetNewAccessTokenを実行します。

f:id:ysmn_deus:20201019170106p:plain

問題が起こらなければ何事も無かったかの用に処理が終わります。
(codeを生成して時間がたっているとエラーが発生するかも知れません)
スプレッドシートtokenのシートにrefresh_tokenなどが生成されているのを確認しておきましょう。

f:id:ysmn_deus:20201019170507p:plain

トークンが生成されていれば、後はこのtoken.gsを直接触ることは無いかと思います。

実行する

それではとりあえず実行してみます。
取得する関数はgetSuperChatEventsとして宣言しています。getSuperChatEventsを実行して下さい。

f:id:ysmn_deus:20201019171230p:plain

問題無ければtokenを作成していたスプレッドシートに実行時間の名前がついたシートが追加されていると思います。

f:id:ysmn_deus:20201019171429p:plain
収益化もされてないアカウントで実行してるのでなにも表示されていない

スーパーチャットが有効化されていて何件か取得できる場合はこのようになります。

f:id:ysmn_deus:20201019172140p:plain

問題点

上記まで実行できれば「おっ、これでスプレッドシートにデータが集約できて便利やな!」という感じなんですが、様々な問題点がありました。
もしかしたら今後APIが改善されるかもしれないので、さしあたり今回用意したスクリプトの意図などを列挙しておきます。

  • SuperChatはアカウント(チャンネル)に対して発生するイベントであり、どのライブで発生したものかは現段階では判別できない
  • SuperChatEvents: listは最新のSuperChatを取得するAPIなので特定期限~現在まで、という縛りでシートに書き出す仕様にした
    • 全部書き出したいって人はmain.gsの1行目にあるafterDateに格納されている日付をかなり古い日付にすればたぶん大丈夫
  • 一番上でも書いたが最新50件のみ取得可、50件以上古いデータは取得できない。これは実際に観測しましたし、海外のフォーラムなどでも言及がありました
  • 上記の制約込みで考えると、GASの最短実行間隔である1分の中でスーパーチャットが50件未満であれば、ライブ時に1分間隔で実行するトリガーを作成して毎分getSuperChatEventsを実行するというのはできそう
    • ただし、人気ライバーさんの記念放送なんてのは1分間に50件以上なんて余裕で来てそうなのでむり
    • 仮に非同期処理で5秒間隔などでgetSuperChatEventsを実行しまくる策も無くは無さそうだが、先にGASのなにがしかの実行制限に到達してしまいそう
    • GCPの有料アカウントで実行することも考えられるが金払うならインスタンス立ち上げて実行しまくった方が確実

今後の課題

たぶんYouTube Live Streaming APIのチャットを取得するAPIを利用してスーパーチャットのみを抽出するのが確実と思われます。
ただし、このAPIライブ配信時に取得し続ける方針は同時接続人数5000~6000ぐらいの状況下で1分あたりのクオータコストが60ぐらいでした。
1アカウントに割り当てられた日にちの上限は10000なので、この規模のライバーさんの放送だと2.7時間が限度という事になります。 実用的で無いこともないんですが、コメント数が増えればクオータコストが上がる可能性がある(こちらは機会があれば別記事で紹介します)ので心許ない気がします。

ちなみに、SuperChatEvents: listのコストは直接分からないんですがたぶん1か2です。チャットを全部取得するよりはコストが低いので可能性としてはこちらを3秒に1回実行する(16件/秒程度のスパチャ速度まで捌ける、一日MAX8時間程度)のが現実的かもしれません。