💭

くじ引き読書法を実践するWebページをnext.jsで作る

2024/02/25に公開

くじ引き読書法とは

技術書の読書術という書籍で紹介されている、適当に選んだ本棚の前に立ち、目をつぶった状態で手に触れた本を読破するという読書法です。

図書館や書店などで、普段読まないようなジャンルの本棚の前に立って行うことで、
思いもよらない世界との出会いがあるという面ですごく魅力的な読書法だと思います。

その一方で、ジャンルを決めていることや、
本棚の一番上や一番下など、物理的に取りにくい位置にある本に手が届きにくいなどの問題で、
完全な意味でランダムに本を選ぶのは難しいという問題もあります。
(この点は本の中でも言及があります。)

そこで、最近触り始めたnext.jsを使って簡単なWebページを自作してみました。

Webページの挙動


上記がトップページで、「新しい本に出会う」ボタンを押すと、


本の検索が始まり、、、、


選ばれた本が表示されます。

「この本を購入」ボタンを押すと、別タブで版元ドットコムの書籍のページが開きます。

また、以下のような挙動を今後できれば追加できればと考えています。

私自身の願望として、くじ引き読書法になるべくお金をかけたくないと思っているのと、
荷物が嵩張るのが嫌い&安いと言う理由で、ほとんどの本をKindleで読むからです。

  • Xにくじ引きの結果選ばれた本についての情報をポストできる機能
  • Amazonに売っている本の中から選べるようにする機能
  • Kindle UnimitedやPrime Readingの対象の本に絞って検索する機能

※このWebページはVercelを使ってデプロイしていますが、商用利用が禁止されているはずなので、
ここまでやろうと思うとインフラを自分で構築する必要性が生じそうですね。
(AmazonのAPIを使うのに、Amazonアソシエイトプログラムの登録が必要になるはずで、
この辺りのことが商用利用と判断されそうで怖いので、、、)

プログラムについて

ひとまず、最低限の動くものをさっさと作ることを優先して作ったので、
プログラムは汚いです。

先述した追加機能の実装の前に、主に以下の点を改善したいと考えています。

  • コンポーネント化する
  • テストコードがないので、書く
  • TypeScriptの良さである型をうまく利用できてないので、改善する
  • せっかくnext.jsを使ったのだから、SSRを使って実装する
page.tsx
"use client";
import { useState } from "react";
import axios from "axios";

const RandomBookSelector = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [bookInfo, setBookInfo] = useState<any>(null);

  // 無作為にISBNを生成する処理
  const generateRandomISBN = () => {
    let isbn = "";
    const digits = "0123456789";
    // 最初の3桁は978or979
    const first3Digits = Math.random() < 0.5 ? "978" : "979";
    // 978or979の次は4で固定(日本語の書籍に絞る)
    isbn += first3Digits + "4";

    // 出版社を表す4桁のコードをランダムで生成
    for (let i = 0; i < 4; i++) {
      isbn += digits.charAt(Math.floor(Math.random() * digits.length));
    }

    // 本そのものを表す4桁のコードをランダムで生成
    for (let i = 0; i < 4; i++) {
      isbn += digits.charAt(Math.floor(Math.random() * digits.length));
    }

    /* 最後の1桁はチェックデジット。
    ※チェックデジットとは、バーコードの読み間違いがないかを検査するための数値。
    以下のような手順で作る。
    ・「左から奇数桁の数字の合計」に「偶数桁の数字の合計」を3倍した値を加える
    ・↑の下1桁を特定し、10から引く
    */
    let sum = 0;
    for (let i = 0; i < isbn.length - 1; i++) {
      const digit = parseInt(isbn.charAt(i));
      if (i % 2 === 0) {
        sum += digit;
      } else {
        sum += digit * 3;
      }
    }

    const checkDigit = (10 - (sum % 10)) % 10;
    isbn += checkDigit;

    return isbn;
  };

  /* openBD APIにランダムで生成したISBNを渡して検索することを、
  書籍がヒットするまでやり続ける。
  ※ランダムで生成したISBNに紐づく書籍が必ず実在する保証がないため。
  */
  const fetchBookInfo = async () => {
    setIsLoading(true);
    let isbn;
    do {
      isbn = generateRandomISBN();
      const response = await axios.get(
        `https://api.openbd.jp/v1/get?isbn=${isbn}`
      );
      if (response.data && response.data[0] && response.data[0].summary) {
        setBookInfo(response.data[0].summary);
        setIsLoading(false);
        break;
      }
    } while (true);
  };

  // 版元ドットコムに遷移するためのリンクを生成
  const handleBuyButton = () => {
    const hanmotoLink = `https://www.hanmoto.com/bd/isbn/${bookInfo.isbn}`;
    window.open(hanmotoLink);
  };

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-3xl font-bold mb-4">Random Book Selector</h1>
      {isLoading && (
        <p style={{ fontWeight: "bold", fontSize: "20px" }}>
          <span role="img" aria-label="glass" className="mr-2">
            🔎
          </span>
          本を探しています...
        </p>
      )}
      {!isLoading && bookInfo && (
        <div className="text-center">
          <h2 className="text-2xl mb-3">以下の本が選ばれました!</h2>
          <p style={{ fontWeight: "bold", fontSize: "20px" }}>
            {bookInfo.title}
          </p>
          <p style={{ color: "grey", fontSize: "16px" }}>
            {bookInfo.author}, {bookInfo.publisher},{" "}
            {bookInfo.pubdate.slice(0, 4) + "/" + bookInfo.pubdate.slice(4)}
          </p>
          <div className="flex justify-center items-center">
            <button
              className="bg-blue-500 text-white px-4 py-2 m-5 rounded-md flex items-center"
              onClick={handleBuyButton}
            >
              <span role="img" aria-label="wallet" className="mr-2">
                👛
              </span>
              この本を購入
            </button>
          </div>
        </div>
      )}
      {!isLoading && (
        <button
          className="bg-blue-500 text-white px-4 py-2 rounded-md flex items-center justify-center mb-2"
          onClick={fetchBookInfo}
          disabled={isLoading}
        >
          <span role="img" aria-label="book" className="mr-2">
            📓
          </span>
          新しい本に出会う
        </button>
      )}
    </div>
  );
};

export default RandomBookSelector;

プログラムでやろうとしていることは、無作為に作成したISBNを用いてopenBDにリクエストを投げ、そこから得た情報をもとに画面を表示するというシンプルなものです。

※無作為に作成したISBNに該当する書籍が存在しないこともあるので、
存在することが確認できるまでリクエストを投げ続けています。

デプロイの方法

Vercelを用いました。驚くほど簡単にできます。
サバイバルTypeScriptのこちらの記事が参考になりました。

主な技術要素

  • next.js(14)
  • Tailwind CSS
  • TypeScript
  • Vercel

参考にさせていただいたサイト

https://qiita.com/slangsoft/items/c62023d227a9a9f43f8c
openBDを用いた実装のサンプルとして参考にさせていただきました。
ありがとうございます。

https://zenn.dev/takeaship/articles/isbn-book-gacha
上記の記事で用いられているのはVue.jsで、画面の挙動こそ異なりますが、
裏側でやろうとしている処理の内容はほぼ同じである認識です。
(設計をする上で参考にさせていただきました。ありがとうございます。)

https://typescriptbook.jp/tutorials/vercel-deploy
デプロイする際に参考にさせていただきました。
ありがとうございます。

Discussion