坂本研のゼミ室

Youtube Data API を使って動画を取得する

作ったもの

フォームに入力したキーワードに一致するYoutube動画を表示します。

f:id:TakaShinoda:20210711203728p:plain

APIキーの取得

Google Cloud PlatformからYoutube Data API v3 のキーを取得します。

参考記事

APIキーを登録

.envファイルに取得したAPIキーを記述します。 今回はNext.jsを使っています。

# youtube api key
NEXT_PUBLIC_YOUTUBE_API_KEY=○○○○○○○○○○○

フォーム作成

検索キーワードを入力するフォームを作成します

<form onSubmit={handleSubmit}>
    <input
        type="text"
        name="keyword"
        value={keyword}
        onChange={handleInputKeyword}
    />
</form>

動画取得

APIを実行して動画のデータを取得します。今回は再生回数が多い順に30件取得しています。その他のプロパティは下記ドキュメントを参考にしてください。

developers.google.com

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()
  await axios
    .get(
      `https://www.googleapis.com/youtube/v3/search?type=video&part=snippet&q=${keyword}&maxResults=30&order=viewCount&key=${process.env.NEXT_PUBLIC_YOUTUBE_API_KEY}`
    )
    .then((res) => {
      setVideoItems(res.data.items)
    })
    .catch((err) => {
      console.log(err)
    })
}

レスポンス内容は下記のようになります。

{
    "kind": "youtube#searchResult",
    "etag": "kDTRucohFNe7n0uBO5psgi89I0U",
    "id": {
        "kind": "youtube#video",
        "videoId": "YZia5Z-oycQ"
    },
    "snippet": {
        "publishedAt": "2014-04-16T00:43:46Z",
        "channelId": "UCS_dOTlDQhzGR2VLASzVo4g",
        "title": "お腹が空いたと鳴くマンチカン子猫!  The Munchkin cat meowing.So cute kitten!",
        "description": "baby #猫 #meow 食事の支度を始めると、キッチンを金網越しに覗いて、ニャーニャーとご飯の要求をし続けている白い子猫と、一緒に鳴いてる他のねこたち。5匹とも ...",
        "thumbnails": {
            "default": {
                "url": "https://i.ytimg.com/vi/YZia5Z-oycQ/default.jpg",
                "width": 120,
                "height": 90
            },
            "medium": {
                "url": "https://i.ytimg.com/vi/YZia5Z-oycQ/mqdefault.jpg",
                "width": 320,
                "height": 180
            },
            "high": {
                "url": "https://i.ytimg.com/vi/YZia5Z-oycQ/hqdefault.jpg",
                "width": 480,
                "height": 360
            }
        },
        "channelTitle": "Cynthia Moon短足だってイイじゃん",
        "liveBroadcastContent": "none",
        "publishTime": "2014-04-16T00:43:46Z"
    }
}

動画を表示する

取得した動画データを元にUIを構築します。

<table>
  <tbody>
    {videoItems.map((video, i) => (
      <tr key={i}>
        <td>
          <Image
            src={video.snippet.thumbnails.medium.url}
            alt="thumbnailsUrl"
            height={video.snippet.thumbnails.medium.height}
            width={video.snippet.thumbnails.medium.width}
          />
        </td>
        <td>
          <strong>
            <a
              href={`https://www.youtube.com/watch?v=${video.id.videoId}`}
              target="_blank"
              rel="noopener noreferrer"
            >
              {video.snippet.title}
            </a>
          </strong>
          {video.snippet.channelTitle}
          <br />
          <span>
            {video.snippet.description}
          </span>
        </td>
      </tr>
    ))}
  </tbody>
</table>

おわりに

Youtube Data API を使って動画のデータを取得しました。 検索プロパティがまだたくさんあるので他にも色々できそうです。

Vercel にホスティングしているサイトに Basic 認証をつける

はじめに

Vercelにホスティングしているサイト(create-react-app)にBasic認証をつける時に、 調べた事をメモしていきます。

下記サイトを参考にさせていただきました。

Vercel で Basic 認証付きのプレビュー環境を作る - Webdelog

Next.js + Vercel で Basic 認証をかける

manifest.jsonはBasic認証がかかっていると通らない | 怪しい物を開発するブログ

実装

static-auth と safe-compare をインストールする

npm i static-auth
npm i safe-compare

www.npmjs.com

www.npmjs.com

server.js を作成

こちらは参考にさせていただいたサイトのコードを使用させていただきました。

const protect = require('static-auth')
const safeCompare = require('safe-compare')

const USER_NAME = process.env.USER_NAME || 'admin'
const PASSWORD = process.env.PASSWORD || 'admin'

const server = protect(
  '/',
  (username, password) => safeCompare(username, USER_NAME) && safeCompare(password, PASSWORD),
  {
    directory: `${__dirname}/build`,
    onAuthFailed: (res) => {
      res.end('Authentication failed')
    },
  }
)

module.exports = server

vercel.json を作成

{
    "builds": [
      {
        "src": "server.js",
        "use": "@vercel/node"
      }
    ],
    "routes": [
      { 
        "src": "/.*", 
        "dest": "server.js" 
      }
    ]
  }

デプロイ

下記実行してサイトを確認する

vercel --prod

f:id:TakaShinoda:20210529162822p:plain

エラー

Basic 認証をつけるとmanifest.json でエラーで出ていたので、 public / html のmanifest 部分に crossorigin="use-credentials" を追加する

<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials"/>

位置情報からテイクアウトやってるお店を教えてくれるLINEbot

作ったもの

位置情報を送るとそこから半径500m以内のテイクアウトやっているお店を最大10件、近い順にカルーセル表示するLINEのbotです。

f:id:TakaShinoda:20210129112906p:plain

ソースコードは下記リポジトリにあります。

github.com

主に使用した技術

  • node.js (v12.14.0で動作確認済み)
  • yelp API
  • LINE Messaging API
  • vercel
  • いらすとや

やったこと

オウム返しbotを作成

  • LINEbotは初めてだったので、ますは下記を参考にしてオウム返しをするbotを作成しました。

1時間でLINE BOTを作るハンズオン (資料+レポート) in Node学園祭2017 #nodefest - Qiita

yelpに登録

  • yelpに登録してAPI Keyを取得します。

f:id:TakaShinoda:20210129204939p:plain

  • .envファイルにyelpのAPI Keyを追加します。 .envファイルをまだ作ってない場合は下記コマンドで作成
npm install dotenv
touch .env
# LINEのチャネルシークレットとチャネルアクセストークン
CHANNEL_SECRET = "XXXXXXXXXXXX"
CHANNEL_ACCESS_TOKEN = "XXXXXXXXXXXX"

# 今回取得したyelpのAPI Keyを追記
YELP_API_KEY = "XXXXXXXXXXXX"

テイクアウトbot作成

  • .envから値を読み込むためオウム返しbotのserver.jsに下記を追加
require('dotenv').config();
axiosをインストール

yelp APIを用いて通信を行うためaxiosをインストールします。

npm install axios
「位置情報を送信してね!」と返す

今回はユーザから位置情報が欲しいので、位置情報以外が送られた場合は「位置情報を送信してね!」と返します。

if (event.type !== 'message' || event.message.type !== 'location') {
      return client.replyMessage(event.replyToken, {
          type: 'text',
          text: '位置情報を送信してね!'
      })
 }
緯度・経度を取得

送られたきた位置情報から緯度・経度を取得します。

// 緯度
const lat = event.message.latitude
// 経度
const lng = event.message.longitude
店舗情報を取得

axiosを使って通信を行い店舗情報を取得します。

let yelpREST = axios.create({
    baseURL: "https://api.yelp.com/v3/",
    headers: {
      Authorization: `Bearer ${process.env.YELP_API_KEY}`,
      "Content-type": "application/json",
    },
  })

  await yelpREST.get("/businesses/search", {
    params: {
      latitude: lat, // 取得した緯度
      longitude: lng, // 取得した経度
      radius: 500, // 今回は半径500m
      term: "takeout", // テイクアウト
      sort_by: "distance", // 距離でソート
      limit: 10, // 最大10件
    },
  })

(略)

その他パラメータはyelpのサイトを参照してください。

Business Search Endpoint - Yelp Fusion

レスポンスは下記のようになりました。(例)

{
  businesses: [
    {
      id: '12-34xx56xx78xx90xxxxxx',
      alias: 'XXXXXX-渋谷区',
      name: 'Shibuya XXXXXX',
      image_url: 'https://sample.jpg',
      is_closed: false,
      url: 'https://www.yelp.com/biz/xxxxxxxxxxxxxx',
      review_count: 5,
      categories: [Array],
      rating: 4.5,
      coordinates: [Object],
      transactions: [],
      price: '¥¥¥',
      location: [Object],
      phone: '+0123456789',
      display_phone: '+81 0-1234-5678',
      distance: 30.987654321
    },
(略)
}
店舗情報をカルーセル表示する

上記通信成功後レスポンスから欲しいものを選択して、カルーセル表示します。詳しくは公式ドキュメントを参照ください。

Messaging APIリファレンス | LINE Developers

(略)
.then(function (response) {
        // handle success
        // データがない場合は「近くにお店はありません!」と表示する
        if(response.data.total === 0) {
            return client.replyMessage(event.replyToken, {
                type: 'text',
                text: '近くにお店はありません!'
            })
        }
          // carouselは最大10
          // colums配列にデータを入れていく
          let columns = [];
          for (let item of response.data.businesses) {
            columns.push({
              "thumbnailImageUrl": item.image_url,
              "title": item.alias,
              "text": '⭐️' + item.rating,
              "actions": [{
                "type": "uri",
                "label": "yelpでみる",
                "uri": item.url
              }]
            });
          }
          // replyMessageの第二引数を配列にすることで複数メッセージを送信できる
        return client.replyMessage(event.replyToken, [
          {
            type: 'text',
            text: 'おいしそうだね!'
          },
          {
            type: 'template',
            altText: '店舗情報を送信しました!',
            template: {
                type: 'carousel',
                columns: columns
            }
        }]);
    })
デプロイ

下記記事を参考にさせていただき、vercelでデプロイを行いました。

VercelでLINE BOTを動かす 2020年5月版 - Qiita

vercel.json "version": 2 は現在はなくても大丈夫そうでした。

{
    "routes": [
        { "src": "/", "dest": "api/server.js" },
        { "src": "/webhook", "dest": "api/server.js" }
    ]
}

まとめ

yelpのAPIを使ってテイクアウトできるお店を教えてくれるLINEbotを作成しました。

今回作成したLINEbotは下記を読み込む事でお友達に追加できますのでよかったら追加してみてください。

f:id:TakaShinoda:20210131183025p:plain

Next.js + TypeScript + Tailwind CSS でブログサイトを作成

はじめに

タイトルにもある通り、Next.js + TypeScript + Tailwind CSS を用いてブログサイトを作成した時のメモ的な感じでここに記させていただきます。

下記のバージョンで操作確認しております。

  "dependencies": {
    "next": "^10.0.0",
    "react": "16.13.1",
    "react-dom": "16.13.1",
    "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.1"
  },

github.com

Next.jsでブログサイトを作成

ブログのベースはNext.jsの公式ドキュメントにあるチュートリアル通りに作成しました。

Qiitaに日本語訳の記事もあり参考になりました。

TypeScript化

TS化を行いました。

  • tsconfig.jsonを作成
touch tsconfig.json
  • typescriptをインストール
npm install --save-dev typescript @types/react @types/node

.jsファイルを.tsxに変更して、型をつけていきました。 GetStaticProps、GetStaticPaths、GetServerSidePropsといった Next.js固有の型もあります。

API (getStaticProps、getStaticPaths、getServerSideProps) については下記記事を参考にさせていただきました。

Tailwind CSSに置き換える

Tailwind CSSの導入の方法は下記の記事を参考にさせていただきました。

また、tailwind.config.js をカスタマイズしてクラス名を追加しました。

module.exports = {
  purge: ['./pages/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
  darkMode: false, // or 'media' or 'class'
  theme: {
    extend: {
      margin: {
        auto: 'auto'
      },
      maxWidth: {
        180: '180px'
      },
    },
  },
  variants: {
    extend: {},
  },
  plugins: [],
}

つまづいた点

  • Tailwind CSSを導入した際に下記エラーが出ました。
Error: PostCSS plugin tailwindcss requires PostCSS 8.

こちらはTailwind CSSのサイトにて下記のように記載がありました。

Installation - Tailwind CSS

If you run into the error mentioned above, uninstall Tailwind and re-install using the compatibility build instead:

上記のエラーが発生した場合は、Tailwindをアンインストールし、互換性のあるビルドを使用して再インストールしてください。

npm uninstall tailwindcss postcss autoprefixer
npm install tailwindcss@npm:@tailwindcss/postcss7-compat @tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9

Tailwind CSS v2.0以降PostCSS8に依存しており、PostCSS8はまだ数か月しか経っていないため、エコシステム内の他の多くのツールはまだ更新されていません。 よって、Tailwind CSSのインストール後にターミナルでこのようなエラーが表示される場合があるそうです。

編集・追加

ブログ記事をカードのように表示できるようにコンポーネントを追加しました。また、カードに表示する画像をmarkdownファイル(/posts)に設定している場合はその画像を表示し、それ以外はデフォルトの画像を表示します。

import React from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { FaRegCalendarAlt } from 'react-icons/fa'
import { Date } from '../components/date'

export const CardList = ({ allPostsData }) => {
  return (
    <>
      <ul className="list-none">
        {allPostsData.map(({ id, date, title, image }) => (
          <div className="inline-flex mr-5 max-w-180" key={id}>
            <li className="mb-5 rounded-lg overflow-hidden shadow-xl p-3 w-44 bg-white">
              {image ? (
                <Image
                  src={image}
                  width="200"
                  height="200"
                  className="w-24 h-24"
                  alt="thumbnail"
                />
              ) : (
                <Image
                  src="/images/no_image.png"
                  width="200"
                  height="200"
                  className="w-24 h-24"
                  alt="no-image"
                />
              )}
              <br />
              <Link href={`/posts/${id}`}>
                <a>{title}</a>
              </Link>
              <br />

              <small className="text-gray-400">
                <span className="mr-1">
                  <FaRegCalendarAlt />
                </span>
                <Date dateString={date} />
              </small>
            </li>
          </div>
        ))}
      </ul>
    </>
  )
}

f:id:TakaShinoda:20210111150858p:plain

参考: Tailwind CSS入門 - フロントエンドで素晴らしい開発体験を得るために - パンダのプログラミングブログ

おわりに

今回は、Next.jsのチュートリアルで作成したブログサイトに、TypeScriptとTailWind CSSを導入しました。 今後はogpを設定したり、記事を投稿してさらに改善していきたいです。

react-axeでアクセシビリティの向上を目指す

はじめに

前回の記事で紹介させていただいたサイトのアクセシビリティをチェックしたら71点でした。

sketchy-kitchenのa11y

本記事では、下記の記事を参考にさせていただき、react-axeを用いてアクセシビリティの向上を目指します。

tech.mercari.com

本記事で記述しているのはこのWebサイトのトップページのみの修正箇所です。

やったこと

react-axeを導入

react-axeを参考記事と同様に導入します

  • react-axeと@types/react-axeをインストール
npm i react-axe
npm i @types/react-axe
  • 開発環境で結果を確認したいので dynamic import でライブラリをインポート

if (process.env.NODE_ENV !== 'production') {
  import('react-axe').then((axe) => {
    axe.default(React, ReactDOM, 1000)
    ReactDOM.render(<App />, document.getElementById('root'))
  })
} else {
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    document.getElementById('root')
  )
}

こうする事でコンソールに New aXe issuesというのが出力されています。

さらにリンクをクリックすると詳しい詳細を確認する事ができました。(日本語に対応していました)

あとは New aXe issuesの通りに修正していきます。

New aXe issues

以下に実際に出力されたものを記します。

critical: Buttons must have discernible text

ボタンには認識可能なテキストが存在しなければなりません

詳細はこちらから確認できます

ボタン要素にaria-label属性を追加しました。

<Button
    aria-label="キーワードで検索"
    size="large"
    variant="contained"
    color="default"
    className={classes.button}
    onClick={() => history.push('/search')}
>
    キーワードで探す
</Button>

serious: Elements must have sufficient color contrast

テキスト要素は背景に対して十分な色のコントラストがなければなりません

詳細はこちらから確認できます

ヘッダーの背景色と文字の色のコントラストをつけました。

moderate: Document must have one main landmark

ドキュメントにはmainランドマークが1つ含まれていなければなりません

詳細はこちらから確認できます

mainタグを記述していなかったのでmainタグを記述しました。

リンクには認識可能なテキストが存在しなければなりません

詳細はこちらから確認できます

リンク要素にaria-label属性を追加しました。

moderate: Page must contain a level-one heading

ページにはレベル1の見出しが含まれていなければなりません

詳細はこちらから確認できます

h2タグで記述していた部分をh1タグに修正しました。

moderate: All page content must be contained by landmarks

ページのすべてのコンテンツはlandmarkに含まれていなければなりません

詳細はこちらから確認できます

メインとなる部分のコンテンツをmainタグで囲んでいなかったので、mainタグの中に記述しました。

おわりに

トップページ以外も全て同じようにreact-axeの New aXe issuesに従い修正を行いました。

その結果アクセシビリティのスコアも100点にする事ができました。

f:id:TakaShinoda:20200531002400p:plain

修正後のソースコードはこちらにあります。

github.com

参考サイト

tech.mercari.com

github.com

最近作ったレシピ投稿サイトとそれに用いた技術

はじめに

STAY HOME週間 で家にいる事が多くなり、初めてまともな自炊を始めました。

そこで、自分が作った料理のレシピを投稿するサイトを作りました。

作った物

レシピ投稿サイトです。

キーワードで検索、レシピを全て一覧表示、レシピ投稿の3つの機能があります。

sketchy-kitchen.com

  • キーワード検索

レシピをCloud Firestoreに保存する時にタグを1つ以上登録してもらって、それをもとに検索します。検索結果ページより画像をクリックする事でレシピの詳細ページに遷移します。

  • 一覧表示

Cloud Firestoreから全て表示します。画像をクリックする事でレシピの詳細ページに遷移します。

  • 投稿

画像をCloud Storageに保存して、ダウンロードURLを取得し、それと共に、料理名、材料などの情報をCloud Firestoreに保存します。

f:id:TakaShinoda:20200517033114p:plain

ソースコードは以下のリポジトリにあります。

github.com

環境

node

v12.11.1

create-react-appをインストールしてある

npm install -g create-react-app

React

Reactは公式サイトでは以下のように言われています。 チュートリアルもあるので詳しくは公式サイトを見てみてください。

ユーザインターフェース構築のためのJavaScriptライブラリ

(公式サイトより)

Reactはcreate-react-appというReactアプリの雛形を簡単に作成できる便利な物があります。

以下のコマンドを実行します。

npx create-react-app プロジェクト名 --typescript

--typescriptはオプションでこれをつけるとReact + TypeScriptの環境を構築できます。(つけなくてもReactの環境は構築できます)

作成されたフォルダに移動

cd フォルダ名

実行して動作を確認

npm start

ja.reactjs.org

Firebase

FirebaseはmBaasと言われいます。 Googleが提供している包括的なアプリ開発プラットフォームで様々な機能がありますが、以下の3つを利用しました。

firebase.google.com

Cloud Storage

Cloud Storageはファイルを保存・配信する事ができます。 今回は、ユーザからアップロードされた画像を保存するために用いました。

const storageRef = firebase.storage().ref('images').child(`${postIndex}.jpg`)
const snapshot = storageRef.put(image)
以下略

Cloud Firestore

Cloud FirestoreはNoSQLデータベースです。 Cloud Storageにアップロードされた画像のダウンロードURL、ユーザが入力した材料などの情報を 保存するために使用しました。

const downloadURL = await storageRef.getDownloadURL()
const db = firebase.firestore()
     db.collection('tileData').add({
       image: downloadURL,
       title: data.title,
以下略
     })

Firebase Hosting

Firebase Hostingホスティングに用いました。

Material-UI

Material-UIはマテリアルデザインをReactに導入する事ができるUIコンポーネントです。 アプリのデザインを手軽にいい感じにする事ができます。

material-ui.com

React Hook Form

フォームバリデーションライブラリです。 ユーザがレシピを入力するフォームにバリデーションをかけました。 公式サイトでは、他のバリデーションライブラリとコードの記述量やレンダリング数を比較してありました。

react-hook-form.com

余談ですが、React Hook FormさんはTwitterでお話しやいいねをしてくれます😄

useForm

React Hook FormのAPIです。 useFormを呼び出す事で様々なメソッドを受け取る事ができます。詳しくは公式サイトをご覧ください。 今回は、registerhandleSubmitを使いました。

const { register, handleSubmit } = useForm()
register

このメソッドを使用して、フォームにバリデーションルールを登録することができます。

※ Material-UIと併用

<TextField inputRef={register({ required: true })} />
handleSubmit

handleSubmitは、フォームバリデーションに成功するとフォームデータを渡し事ができます。

const onSubmit = (data) => {
   ....略....
}

<form onSubmit={handleSubmit(onSubmit)}>
     <TextField inputRef={register({ required: true })} />
     <button type="submit">Submit</button>
</form>

React Router

ルーティングするために用いました。

reacttraining.com

useHistory

React RouterのHooks APIです。 ページ遷移を行う時にhistoryを取得します。

※ Material-UIと併用

const history = useHistory()

<Button onClick={() => history.push('/home'}>

useParams

URLのパスの中で動的に変化する部分の値を取得します。 レシピの詳細ページを1つ用意して、URLに応じて中身を変えています。

おわりに

まだ完全にできた訳ではないので、これからも改良していきたいと思います。

あと、まともに自炊する時間も無くなってきました😅

react-testing-libraryでテストをやってみて

はじめに

以前にcreate-react-appで作成したReactアプリをテストしてみるというJestとEnzymeを使ってテストを行う記事を書きましたが、

今回は、以下の記事を参考にしてReact Testing Libraryを使ってみました。

本記事は実際に手を動かしてみた感想ですので、詳しくは参考記事をご覧ください。

www.freecodecamp.org

では手を動かしていきます。

テスト実行時のエラー

記事通りに手を動かしていくと、2. Testing DOM elements (DOM要素のテスト)の所で以下のようなエラーが出ました

  • TestElements.test.js
import React from 'react'
import { render, cleanup } from '@testing-library/react'
import { TestElements } from './TestElements'

afterEach(cleanup)

// カウンターが0に等しいか
it('should equal to 0', () => {
    const { getByTestId } = render(<TestElements />)
    expect(getByTestId('counter')).toHaveTextContent(0)
})

// ボタンが有効か無効か
it('should be disabled', () => {
    const { getByTestId } = render(<TestElements />)
    expect(getByTestId('button-down')).toBeDisabled()
})
TypeError: expect(...).toHaveTextContent is not a function

TypeError: expect(...).toBeDisabled is not a function

github.com

上記のissuesにある通りにimport "@testing-library/jest-dom/extend-expect";を追加する事で解決しました。以降同じようにやっていきます。

  • TestElements.test.js (修正後)
import React from 'react'
import { render, cleanup } from '@testing-library/react'
import { TestElements } from './TestElements'
import '@testing-library/jest-dom/extend-expect'

afterEach(cleanup)

// カウンターが0に等しいか
it('should equal to 0', () => {
    const { getByTestId } = render(<TestElements />)
    expect(getByTestId('counter')).toHaveTextContent(0)
})

// ボタンが有効か無効か
it('should be disabled', () => {
    const { getByTestId } = render(<TestElements />)
    expect(getByTestId('button-down')).toBeDisabled()
})

yarn testを実行してテストが通りました🎉

これまで、getByTestIdをなんとなく使ってきましたが以下の記事にTipsとしてgetByTestIdはなるべく避けるという知見を得る事ができました。

qiita.com

これは、クエリというもので公式ドキュメントのガイドにも優先順位が書いてありました。

testing-library.com

testing-library.com

おわりに

react-testing-librarycreate-react-appの場合最初から導入されているので、すぐに始める事ができます。

テストを書いてみるのは2回目でしたが、独自のヘルパー関数を作る部分の理解が足りずもう少し勉強が必要だと感じました。

自分が実際にやった物はこちらリポジトリにあげていこうと思います。

その他の参考文献

kentcdodds.com