41
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rails API + Reactで作る俺流アニメデータベース

Last updated at Posted at 2021-07-23

スクリーンショット 2021-07-24 4.41.45.png

概要

自分はアニメを見るのが趣味です。昨今のコロナ事情によってリモートワークが基本となった事もあり、以前よりもアニメに没頭する機会が増えました。

毎日のように「今期のアニメで面白そうな作品は無いかなぁ」なんて探しているわけですが、どうもアニメの情報って効率的に取得しづらい気がしています。

もちろん、世の中にはたくさんのアニメ情報サイトが存在しているものの、自分にとっては必要無い情報がたくさん羅列されていたりしてしっくり来ない事もしばしば。

たとえば、私が視聴するアニメを選ぶ基準としては、

  • どんなスタッフが携わっているか
  • どんな声優さんが出演されているか
  • キャラデザインは自分好みか
  • 世間的な注目度は高そうか

といったものが主な判断材料となっています。

要するに、製作陣やキャスト陣、キービジュアルやSNSのフォロワー数などが一目でわかれば情報としてはそれなりに十分というわけですね。

そこで今回は、↑の要件を満たすアプリを自分で作ってみる事にしました。

完成イメージ

マイ-ムービー(10).gif

  • 年代・季節ごとに作品を絞り込み
  • 作品のタイトルで個別に検索
  • 作品のイメージ画像
  • 製作陣やキャスト陣の情報一覧
  • 公式サイトやTwitterアカウントへのリンク

必要最低限な機能・情報がコンパクトにまとまっていると思います。

主な使用技術・サービス

  • バックエンド
    • Ruby
    • Rails API
    • MySQL
  • フロントエンド
    • React
    • TypeScript
  • 外部サービス
    • Annict
      • 見たアニメを記録したり、見た感想を友達にシェアすることができるWebサービス。APIを公開しており、各作品の情報を取得する事ができる。
    • しょぼいカレンダー
      • アニメの番組表などが確認できるWebサービス。こちらもAPIを公開しており、各作品の情報を取得する事ができる。

※ 再現性を考慮してバックエンドのみDockerで環境構築を行います。

Annict、しょぼいカレンダーともにアニメ好きであれば一度は利用した事があるのではないでしょうか。簡単な情報からマニアックな情報まで網羅的に掲載してくれている素晴らしいWebサービスです。

それぞれAPIを公開しているため、素直にそれらを使えば良いんじゃねって思われるかもしれませんが、どちらも個人的には痒いところにあと一歩届かない感があったので、色々こねくり回して扱いやすい形に整形するためバックエンドを準備しました。

実装

前置きはほどほどに実装を開始しましょう。

バックエンド

先にバックエンド側から。

環境構築

何はともあれ環境構築を行います。

各種ディレクトリ・ファイルを作成
$ mkdir aninfo-backend && cd aninfo-backend
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
./Dockerfile
FROM ruby:2.6.3

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs mariadb-client

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 4306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3001:3000"
    depends_on:
      - db
volumes:
  mysql-data:
./entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /myapp/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
./Gemfile.lock
# 空欄でOK
rails new

APIモードで作成します。

$ docker-compose run api rails new . --force --no-deps -d mysql --api
database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
コンテナを起動 & データベースを作成
$ docker-compose build
$ docker-compose up -d
$ docker-compose run api bundle exec rails db:create
localhost:3001 にアクセス

スクリーンショット 2021-07-24 0.30.48.png

localhost:3001 にアクセスして初期状態の画面が表示されればOKです。

gemをインストール

後々の処理で必要になるgemをインストールしておきます。

./Gemfile
gem 'faraday'
gem 'syobocal'
gem 'dotenv-rails'
  • fadaday
    • HTTPクライアント用のgem
  • syobocal
    • しょぼいカレンダーから情報を取得しやすくしてくれるgem
  • dotenv-rails
    • 環境変数を管理するためのgem

Gemfileを更新したので再度ビルド。

$ docker-compose build

各種モデルを作成

$ docker-compose run api rails g model Work title:string year:integer season:integer image:string twitter_username:string official_site_url:string media_text:string season_name_text:string syobocal_tid:integer
$ docker-compose run api rails g model WorkDetail work_id:integer staffs:text casts:text syobocal_tid:integer
$ docker-compose run api rails db:migrate
  • Work(作品) ※ Annictから取得する情報
    • title
      • 作品タイトル
    • year
      • 放送年
    • season
      • 季節
    • image
      • 作品イメージ
    • twitter_username
      • Twitterアカウント名
    • official_site_url
      • 公式サイトURL
    • media_text
      • どのメディアで放送か(TV、映画、OVAなど)
    • syobocal_tid
      • しょぼいカレンダーのTID
  • WorkDetail(作品の詳細)※ しょぼいカレンダーから取得する情報
    • work_id
      • Workモデルとの関連付け用
    • staffs
      • 製作陣
    • casts
      • キャスト陣
    • syobocal_tid
      • しょぼいカレンダーのTID
./app/models/work.rb
class Work < ApplicationRecord
  enum season: { spring: 1, summer: 2, autumn: 3, winter: 4 }
  has_one :work_detail

  # Annictから情報を取得
  def import_from_annict
    base_url = "https://api.annict.com/v1"
    access_token = ENV["ANNICT_ACCESS_TOKEN"]
    
    start_year = 1970 # どの年からデータを取得したいかを指定
    end_year = Date.today.year
    seasons = ["spring", "summer", "autumn", "winter"]

    (start_year..end_year).each do |year|
      seasons.each.with_index(1) do |season, index|
        # 初回リクエストはデータの総数を調べるために実行
        data = JSON.parse(Faraday.get("#{base_url}/works?fields=id&per_page=50&filter_season=#{year}-#{season}&sort_watchers_count=desc&access_token=#{access_token}").body)
        
        data_count = data["total_count"]         # データの数
        page_count = (data_count / 50.to_f).ceil # ページの数

        current_page = 1

        # 現在のページ <= ページの数になるまで繰り返し処理を実行
        while current_page <= page_count do
          data = JSON.parse(Faraday.get("#{base_url}/works?fields=title,images,twitter_username,official_site_url,media_text,syobocal_tid,season_name_text&page=#{current_page}&per_page=50&filter_season=#{year}-#{season}&sort_watchers_count=desc&access_token=#{access_token}").body)
          works = data["works"]

          works.each do |work|
            # すでにレコードが存在する場合は更新、無ければ新規作成
            Work.find_or_initialize_by(title: work["title"]).update(
              year: year,
              season: index,
              image: work["images"]["recommended_url"],
              twitter_username: work["twitter_username"],
              official_site_url: work["official_site_url"],
              media_text: work["media_text"],
              syobocal_tid: work["syobocal_tid"],
              season_name_text: work["season_name_text"]
            )
          end

          current_page += 1
        end
      end
    end
  end
end
./app/models/work_detail.rb
class WorkDetail < ApplicationRecord
  serialize :staffs, Array
  serialize :casts, Array

  belongs_to :work

  # しょぼいカレンダーから情報を取得
  def import_from_syobocal
    titles = Syobocal::DB::TitleLookup.get({ "TID" => "*" })
    
    titles.each do |title|
      comment = title[:comment]
      parser = Syobocal::Comment::Parser.new(comment)

      # 製作陣
      staffs = parser.staffs.map do |staff|
        {
          "role": staff.instance_variable_get("@role"),
          "name": staff.instance_variable_get("@people")[0].instance_variable_get("@name")
        }
      end

      # キャスト陣
      casts = parser.casts.map do |cast|
        {
          "character": cast.instance_variable_get("@character"),
          "name": cast.instance_variable_get("@people")[0].instance_variable_get("@name")
        }
      end

      tid = title[:tid]
      work = Work.find_by(syobocal_tid: tid)

      # すでにレコードが存在する場合は更新、無ければ新規作成
      WorkDetail.find_or_initialize_by(syobocal_tid: tid).update(
        work_id: work ? work.id : nil,
        staffs: staffs,
        casts: casts
      )
    end
  end
end

各情報をデータベースにインポート

Annict、しょぼいカレンダーから各情報をデータベースにインポートします。ただし、Annictに関してはAPIを利用するためのアクセストークンが必要になるので、公式ドキュメントの手順に従い事前に取得しておいてください。

Annict API 公式ドキュメント

アクセストークンが取得できたら、ルートディレクトリに「.env」ファイルを作成してそこに環境変数としてセットします。

$ touch .env
.env
ANNICT_ACCESS_TOKEN=***********************

その後、Railsコンソールを立ち上げてそれぞれインポートを開始してください。

$ docker-compose run api rails c

irb(main):001:0> Work.new.import_from_annict
  Work Load (0.6ms)  SELECT `works`.* FROM `works` WHERE `works`.`title` = 'あしたのジョー' LIMIT 1
  TRANSACTION (0.4ms)  BEGIN
  Work Create (0.6ms)  INSERT INTO `works` (`title`, `year`, `season`, `image`, `twitter_username`, `official_site_url`, `media_text`, `season_name_text`, `created_at`, `updated_at`) VALUES ('あしたのジョー', 1970, 1, '', '', '', 'TV', '1970年春', '2021-07-23 16:04:36.882115', '2021-07-23 16:04:36.882115')
  TRANSACTION (2.2ms)  COMMIT

...

irb(main):002:0> WorkDetail.new.import_from_syobocal
  Work Load (4.5ms)  SELECT `works`.* FROM `works` WHERE `works`.`syobocal_tid` = 1 LIMIT 1
  WorkDetail Load (0.5ms)  SELECT `work_details`.* FROM `work_details` WHERE `work_details`.`syobocal_tid` = 1 LIMIT 1
  TRANSACTION (0.3ms)  BEGIN
  Work Load (2.0ms)  SELECT `works`.* FROM `works` WHERE `works`.`id` = 2194 LIMIT 1
  WorkDetail Create (0.8ms)  INSERT INTO `work_details` (`work_id`, `staffs`, `casts`, `syobocal_tid`, `created_at`, `updated_at`) VALUES (2194, '---\n- :role: 監督\n  :name: 下田正美\n- :role: 原作・脚本\n  :name: 山田典枝\n- :role: 掲載\n  :name: 月刊コミックドラゴン\n- :role: キャラクター原案\n  :name: よしづきくみち\n- :role: キャラクターデザイン\n  :name: 千葉道徳\n- :role: 総作画監督\n  :name: 川崎恵子\n- :role: コンセプト・ワークス\n  :name: 横田耕三\n- :role: 美術監督\n  :name: 西川淳一郎\n- :role: 色彩設定\n  :name: 石田美由紀\n- :role: 撮影監督\n  :name: 秋元央\n- :role: 編集\n  :name: 西山茂\n- :role: 音響監督\n  :name: 田中英行\n- :role: 音楽\n  :name: 羽毛田丈史\n- :role: 音楽プロデューサー\n  :name: 廣井紀彦\n- :role: 音楽ディレクター\n  :name: 和田亨\n- :role: 音楽協力\n  :name: テレビ朝日ミュージック\n- :role: 録音調整\n  :name: 小原吉男\n- :role: 音響効果\n  :name: 今野康之\n- :role: 選曲\n  :name: 神保直史\n- :role: 録音助手\n  :name: 国分政嗣\n- :role: 録音スタジオ\n  :name: タバック\n- :role: 音響制作\n  :name: オーディオ・タナカ\n- :role: キャスティング協力\n  :name: 好永伸恵\n- :role: ポストプロダクション\n  :name: 東京現像所\n- :role: 広報\n  :name: 小出わかな\n- :role: 宣伝プロデュース\n  :name: 小林 剛\n- :role: アシスタントプロデューサー\n  :name: 佐々木美和\n- :role: プロデューサー\n  :name: 清水俊\n- :role: アニメーションプロデューサー\n  :name: 新崎力也\n- :role: 企画\n  :name: 角川大映\n- :role: アニメーション制作\n  :name: ヴューワークス\n- :role: 制作\n  :name: 魔法局\n', '---\n- :character: 菊池ユメ\n  :name: 宮﨑あおい\n- :character: 小山田雅美\n  :name: 諏訪部順一\n- :character: ケラ(加藤剛)\n  :name: 飯田浩志\n- :character: アンジェラ\n  :name: 渡辺明乃\n- :character: 遠藤耕三\n  :name: 中博史\n- :character: 古崎力哉\n  :name: 清川元夢\n- :character: 森川瑠奈\n  :name: 石毛佐和\n- :character: ギンプン\n  :name: 辻谷耕史\n- :character: ミリンダ\n  :name: 平松晶子\n', 1, '2021-07-23 16:12:43.543292', '2021-07-23 16:12:43.543292')
  TRANSACTION (2.2ms)  COMMIT

...

スクリーンショット 2021-07-24 1.08.35.png

最終的にこんな感じでそれぞれの情報が格納されていれば成功です。(※ 古い作品などは空欄になってしまう箇所多し)

APIを作成

データベースに格納した情報をJSON形式で返すAPIを作成します。

コントローラー
$ docker-compose run api rails g controller api/v1/works
./app/controllers/api/v1/works_controller.rb
class Api::V1::WorksController < ApplicationController
  def index
    return if !params[:year] && !params[:season] && !params[:title]

    # paramsによって絞り込みの条件を変更
    query = params[:year] && params[:season] ? Work.where(year: params[:year], season: params[:season]) : Work.where("title like ?", "%#{params[:title]}%")
    works = query.map do |work|
      {
        id: work.id,                                             # ID
        title: work.title,                                       # 作品のタイトル
        year: work.year,                                         # 年
        season: work.season,                                     # 季節
        image: work.image,                                       # 画像
        staffs: work.work_detail ? work.work_detail.staffs : [], # 製作陣
        casts: work.work_detail ? work.work_detail.casts : [],   # キャスト陣
        twitter_username: work.twitter_username,                 # Twitterのユーザー名
        official_site_url: work.official_site_url,               # 公式サイトのURL
        media_text: work.media_text,                             # ex. TV、映画、OVA、Web
        season_name_text: work.season_name_text                  # ex. 2021年春
      }
    end
    
    render json: { status: 200, works: works }
  end
end

クエリパラメータに「year」と「season」が含まれていた場合は放送タイミングで作品を絞り込み、「title」が含まれていた場合は合致するタイトルの作品を絞り込むようにしました。

なお、Annictから取得した情報(Work)には製作陣(staffs)やキャスト陣(staffs)が含まれていなかったため、しょぼいカレンダーから取得した情報(WorkDetail)をプラスして情報の網羅性を高めています。

ルーティング
./backend/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
     resources :works, only: %i[index]
    end 
  end 
end
動作確認
$ curl -X GET http://localhost:3001/api/v1/works?year=2021&season=3
$ curl -X GET http://localhost:3001/api/v1/works?title=小林さんちのメイドラゴンS

curlコマンドを叩くなり、直接URLを打ち込んでアクセスするなりしてJSONが返ってくればOK。

CORS設定

今回の構成ではバックエンドとフロントエンドを完全に分けているため、RailsとReactがそれぞれ別のドメインで立ち上がっています。(localhost:3001とlocalhost:3000)

この場合、デフォルトの状態だとセキュリティの問題でReactからRailsのAPIを使用できない点に注意が必要です。

これを解決するためには「CORS(クロス・オリジン・リソース・シェアリング)」の設定を行わなければなりません。

参照記事: オリジン間リソース共有 (CORS)

rack-corsをインストール

RailsにはCORSの設定を簡単に行えるgemがあるのでそちらをインストールしましょう。

./Gemfile
gem 'rack-cors'

APIモードで作成している場合、すでにGemfile内に記載されているのでコメントアウトを外すだけでOKです。

$ docker-compose build

Gemfileを更新したので再度ビルド。

cors.rbを編集

「config/initializers/」に設定ファイルが存在するはずなので、外部からアクセス可能なように編集しておきます。

./backend/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "*"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

設定の変更を反映させるためにコンテナを再起動。

$ docker-compose down
$ docker-compose up -d

これでバックエンド側の準備は完了です。

フロントエンド

次にフロントエンド側の実装に入ります。

環境構築

何はともあれ環境構築を行います。

各種ディレクトリ・ファイルを作成

おなじみの「create-react-app」でアプリの雛形を作ります。

$ mkdir aninfo-frontend && cd aninfo-frontend
$ yarn create react-app . --template typescript
tsconfig.jsonを修正

「./tsconfig.json」内に「"baseUrl": "src"」という1行を追記してください。

./tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,

    ...省略...

    "baseUrl": "src"  追記
  },
  "include": [
    "src"
  ]
}

これにより、インポート先を「./tsconfig.json」が配置されているディレクトリから相対パスで指定できるようになるので非常に楽です。

baseUrlを指定しない場合

import Hoge from "../../components/Hoge" // 呼び出すファイルからの相対パス

baseUrlを指定した場合

import Hoge from "components/Hoge" // baseUrlからの相対パス

いちいち「../../」みたいな記述をしなくて済むというわけですね。

不要なファイルを整理

この先使う事の無いファイルは邪魔なので今のうちに消しておきましょう。

$ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts 

「./src/index.tsx」と「./src/App.tsx」を次のように変更します。

./src/index.tsx
import React from "react"
import ReactDOM from "react-dom"
import "./index.css"
import App from "./App"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)
./src/App.tsx
import React from "react"

const App: React.FC = () => {
  return (
    <h1>Hello World!</h1>
  )
}

export default App

一旦、動作確認してみましょう。

$ yarn start

スクリーンショット 2021-05-04 0.37.55.png

localhost:3000 にアクセスして「Hello World!」と返ってくればOK。

型定義

プロジェクト全体で使い回す事になるであろう型(今回であればWork)を「./src/interfaces/index.ts」の中に記述しておきます。

$ mkdir src/interfaces
$ touch src/interfaces/index.ts
./src/interfaces/index.ts
export interface Work {
  id: number
  title: string
  year: number
  season: number
  image?: string
  staffs?: Array<{
    role: string
    name: string
  }>
  casts?: Array<{
    character: string
    name: string
  }>
  twitterUsername?: string
  officialSiteUrl?: string
  mediaText: string
  seasonNameText: string
}

APIを呼び出すための関数を作成

Railsで作成したAPIを呼び出すための関数を作成します。

$ mkdir src/lib
$ mkdir src/lib/api
$ touch src/lib/api/client.ts
$ touch src/lib/api/works.ts
$ touch .env.local

$ yarn add axios axios-case-converter
$ yarn add -D @types/axios
  • axios
    • HTTPクライアント用ライブラリ
  • @types/axios
    • 型定義用ライブラリ
  • axios-case-converter
    • axios経由で受け取るレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ
./src/lib/api/client.ts
import applyCaseMiddleware from "axios-case-converter"
import axios from "axios"

/* applyCaseMiddleware
axiosで受け取ったレスポンスの値をスネークケース→キャメルケースに変換
または送信するリクエストの値をキャメルケース→スネークケースに変換 */

const railsApiBaseUrl = process.env.REACT_APP_RAILS_API_BASE_URL

const client = applyCaseMiddleware(axios.create({
  baseURL: `${railsApiBaseUrl}/api/v1`
}))

export default client

慣習的にRubyなどの言語がスネークケースが基本であるのに対し、JavaScriptはキャメルケースが基本なので、足並みを揃える(スネークケース→キャメルケースへの変換もしくはその逆)ために「applyCaseMiddleware」というライブラリを使わせてもらっています。

./src/lib/api/works
import { AxiosPromise } from "axios"

import client from "lib/api/client"
import { Work } from "interfaces/index"

interface GetWorksResponse {
  status: number
  works: Work[]
}

// 年・季節・タイトルなどから作品を取得
export const getWorks = (year?: number, season?: number, title?: string): AxiosPromise<GetWorksResponse> => {
  if (year && season) {
    return client.get(`/works?year=${year}&season=${season}`)
  } else {
    return client.get(`/works?title=${title}`)
  }
}

.env.local
REACT_APP_RAILS_API_BASE_URL=http://localhost:3001

※ Reactで環境変数を使用する場合、環境変数名の先頭にREACT_APP_を付ける必要があるので注意。

動作確認
./src/App.tsx
import React, { useEffect, useState } from "react"

import { getWorks } from "lib/api/works"
import { Work } from "interfaces/index"

const App: React.FC = () => {
  const [works, setWorks] = useState<Work[]>()

  const handleGetWorks = async (year?: number, season?: number, title?: string): Promise<void> => {
    const res = await getWorks(year, season, title)

    if (res.status === 200) {
      setWorks(res.data.works)
    }
  }

  useEffect(() => {
    handleGetWorks(2021, 3) // 20201年夏季のアニメ情報を取得
  }, [])

  return (
    <React.Fragment>
      {
        works?.map((work: Work) => (
          <p>{work.title}</p>
        ))
      }
    </React.Fragment>
  )
}

export default App

スクリーンショット 2021-07-24 2.24.34.png

localhost:3000 にアクセスして作品タイトルがズラーっと表示されていればOK。ちゃんと通信ができています。

各種ライブラリをインストール

後に必要となるライブラリをインストールしておきます。

$ yarn add @material-ui/core @material-ui/icons @material-ui/lab react-select
$ yarn add -D @types/react-select
  • material-ui
    • UIを整える用のライブラリ
  • react-select
    • セレクトボックスが簡単に作れるライブラリ
  • @types/react-select
    • 型定義用ライブラリ

各種ビューを作成

各種ビューを作成します。

$ mkdir src/components
$ mkdir src/components/layouts
$ mkdir src/components/utils
$ mkdir src/components/work

$ touch src/components/layouts/Header.tsx
$ touch src/components/utils/theme.ts
$ touch src/components/work/SelectBox.tsx
$ touch src/components/work/WorkDetail.tsx
$ touch src/components/work/Works.tsx
./src/components/layouts/Header.tsx
import React, { useState } from "react"

import AppBar from "@material-ui/core/AppBar"
import Toolbar from "@material-ui/core/Toolbar"
import IconButton from "@material-ui/core/IconButton"
import Typography from "@material-ui/core/Typography"
import InputBase from "@material-ui/core/InputBase"
import { alpha, makeStyles } from "@material-ui/core/styles"
import MenuIcon from "@material-ui/icons/Menu"
import SearchIcon from "@material-ui/icons/Search"

const useStyles = makeStyles((theme) => ({
  root: {
    flexGrow: 1
  },
  menuButton: {
    marginRight: theme.spacing(2)
  },
  title: {
    flexGrow: 1,
    display: "none",
    [theme.breakpoints.up("sm")]: {
      display: "block"
    }
  },
  search: {
    position: "relative",
    borderRadius: theme.shape.borderRadius,
    backgroundColor: alpha(theme.palette.common.white, 0.15),
    "&:hover": {
      backgroundColor: alpha(theme.palette.common.white, 0.25),
    },
    marginLeft: 0,
    width: "100%",
    [theme.breakpoints.up("sm")]: {
      marginLeft: theme.spacing(1),
      width: "auto",
    }
  },
  searchIcon: {
    padding: theme.spacing(0, 2),
    height: "100%",
    position: "absolute",
    pointerEvents: "none",
    display: "flex",
    alignItems: "center",
    justifyContent: "center"
  },
  inputRoot: {
    color: "inherit"
  },
  inputInput: {
    padding: theme.spacing(1, 1, 1, 0),
    paddingLeft: `calc(1em + ${theme.spacing(4)}px)`,
    transition: theme.transitions.create("width"),
    width: "100%",
    [theme.breakpoints.up("sm")]: {
      width: "12ch",
      "&:focus": {
        width: "20ch",
      }
    }
  }
}))

interface HeaderProps {
  handleGetWorks: Function
  setLoading: Function
}

const Header: React.FC<HeaderProps> = ({ handleGetWorks, setLoading }) => {
  const classes = useStyles()

  const [title, setTitle] = useState<string>("")

  return (
    <div className={classes.root}>
      <AppBar position="static">
        <Toolbar>
          <IconButton
            edge="start"
            className={classes.menuButton}
            color="inherit"
            aria-label="open drawer"
          >
            <MenuIcon />
          </IconButton>
          <Typography className={classes.title} variant="h6" noWrap>
            AnInfo
          </Typography>
          <div className={classes.search}>
            <div className={classes.searchIcon}>
              <SearchIcon />
            </div>
            <InputBase
              placeholder="作品名で検索"
              classes={{
                root: classes.inputRoot,
                input: classes.inputInput,
              }}
              inputProps={{ "aria-label": "search" }}
              onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
                setTitle(e.target.value)
                console.log(title)
              }}
              onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
                if (e.key === "Enter") {
                  setLoading(true)
                  handleGetWorks(null, null, title)
                }
              }}
            />
          </div>
        </Toolbar>
      </AppBar>
    </div>
  )
}

export default Header
./src/components/utils/theme.ts
import { createTheme } from "@material-ui/core/styles"
import blue from "@material-ui/core/colors/blue"
import green from '@material-ui/core/colors/green'

const theme = createTheme({
  palette: {
    primary: {
      main: blue[500]
    },
    secondary: {
      main: green[500]
    }
  },
  typography: {
    h1: {
      fontSize: "3rem",
      fontWeight: 500
    },
    h2: {
      fontSize: "2rem",
      fontWeight: 500
    },
    h3: {
      fontSize: "1.25rem",
      fontWeight: 500
    },
    h4: {
      fontSize: "1rem",
      fontWeight: 500
    }
  }
})

export default theme
./src/components/work/SelectBox.tsx
import React, { useState } from "react"
import Select, { OptionTypeBase } from "react-select"

import { makeStyles } from "@material-ui/core/styles"
import Grid from "@material-ui/core/Grid"
import IconButton from "@material-ui/core/IconButton"
import SearchIcon from "@material-ui/icons/Search"
import FormControl from "@material-ui/core/FormControl"

const useStyles = makeStyles(() => ({
  gridContainer: {
    marginBottom: "2rem"
  },
  iconButton: {
    padding: 10
  },
  formControl: {
    margin: "3px",
    minWidth: 130
  }
}))

interface SelectBoxProps {
  years: Array<{
    value: number
    label: string
  }>
  seasons: Array<{
    value: number
    label: string
  }>
  handleGetWorks: Function
  setLoading: Function
}

const SelectBox: React.FC<SelectBoxProps> = ({ years, seasons, handleGetWorks, setLoading }) => {
  const classes = useStyles()

  const [year, setYear] = useState<number>()
  const [season, setSeason] = useState<number>()

  return (
    <Grid className={classes.gridContainer} container justifyContent="center">
      <FormControl className={classes.formControl}>
        <Select
          instanceId="year-select"
          placeholder="年"
          options={years}
          onChange={(e) => {
            setYear(e?.value)
          }}
        />
      </FormControl>
      <FormControl className={classes.formControl}>
        <Select
          instanceId="season-select"
          placeholder="シーズン"
          options={seasons}
          onChange={(e) => {
            setSeason(e?.value)
          }}
          
        />  
      </FormControl>
      <IconButton
        type="submit"
        className={classes.iconButton}
        size="medium"
        color="default"
        disabled={!year || !season}
        onClick={() => {
          setLoading(true)
          handleGetWorks(year, season)
        }}
      >
        <SearchIcon />
      </IconButton>
    </Grid>
  )
}

export default SelectBox
./src/components/work/WorkDetail.tsx
import { createStyles, Theme, withStyles, WithStyles } from "@material-ui/core/styles"

import Button from "@material-ui/core/Button"
import Dialog from "@material-ui/core/Dialog"
import MuiDialogTitle from "@material-ui/core/DialogTitle"
import MuiDialogContent from "@material-ui/core/DialogContent"
import MuiDialogActions from "@material-ui/core/DialogActions"
import Typography from "@material-ui/core/Typography"
import IconButton from "@material-ui/core/IconButton"
import CloseIcon from "@material-ui/icons/Close"

import { Work } from "interfaces/index"
import React from "react"

const styles = (theme: Theme) =>
  createStyles({
    root: {
      margin: 0,
      padding: theme.spacing(2),
    },
    closeButton: {
      position: "absolute",
      right: theme.spacing(1),
      top: theme.spacing(1),
      color: theme.palette.grey[500]
    }
  })

export interface DialogTitleProps extends WithStyles<typeof styles> {
  id: string
  children: React.ReactNode
  onClose: () => void
}

const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
  const { children, classes, onClose, ...other } = props

  return (
    <MuiDialogTitle disableTypography className={classes.root} {...other}>
      <Typography variant="h6">{children}</Typography>
      {
        onClose ? (
          <IconButton aria-label="close" className={classes.closeButton} onClick={onClose}>
            <CloseIcon />
          </IconButton>
        ) : null
      }
    </MuiDialogTitle>
  )
})

const DialogContent = withStyles((theme) => ({
  root: {
    padding: theme.spacing(2),
  }
}))(MuiDialogContent)

const DialogActions = withStyles((theme) => ({
  root: {
    margin: 0,
    padding: theme.spacing(1),
  }
}))(MuiDialogActions)

interface WorkDetailsProps {
  work: Work
  open: boolean
  handleClose: () => void
}

const WorkDetail: React.FC<WorkDetailsProps> = ({ work, open, handleClose }) => {

  return (
    work.staffs != undefined && work.casts != undefined ? (
      <Dialog onClose={handleClose}  open={open} fullWidth>
        <DialogTitle id="customized-dialog-title" onClose={handleClose}>
          {work.title}
        </DialogTitle>
        <DialogContent dividers> 
          <Typography variant="h4" gutterBottom>
            Staffs
          </Typography>
          {
            work.staffs.length > 1 ? work.staffs.map((staff, index: number) => (
              <Typography key={index} variant="body2" gutterBottom>
                {staff.role}: {staff.name}
              </Typography>
            )) : <Typography variant="body2" gutterBottom>情報を取得できませんでした</Typography>
          }
          <Typography variant="h4" gutterBottom style={{ marginTop: "1rem" }}>
            Casts
          </Typography>
          {
            work.casts.length > 1 ? work.casts.map((cast, index: number) => (
              <Typography key={index} variant="body2" gutterBottom>
                {cast.character}: {cast.name}
              </Typography>
            )) : <Typography variant="body2" gutterBottom>情報を取得できませんでした</Typography>
          }
        </DialogContent>
        <DialogActions>
          <Button autoFocus onClick={handleClose} color="primary">
            閉じる
          </Button>
        </DialogActions>        
      </Dialog>
    ) : null
  )
}

export default WorkDetail
./src/components/work/Works.tsx
import React, { useState } from "react"

import { makeStyles } from "@material-ui/core/styles"
import Grid from "@material-ui/core/Grid"
import Card from "@material-ui/core/Card"
import CardMedia from "@material-ui/core/CardMedia"
import CardContent from "@material-ui/core/CardContent"
import CardActions from "@material-ui/core/CardActions"
import Chip from "@material-ui/core/Chip"
import CircularProgress from "@material-ui/core/CircularProgress"
import Typography from "@material-ui/core/Typography"

import WorkDetail from "components/work/WorkDetail"
import { Work } from "interfaces/index"

const useStyles = makeStyles(() => ({
  circularProgress: {
    position: "absolute",
    top: "50%",
    left: "50%"
  },
  card: {
    height: "100%",
    width: "100%",
    marginBottom: "0.5rem",
    transition: "all 0.3s",
    "&:hover": {
      boxShadow:
        "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)",
      transform: "translateY(-3px)",
    }
  },
  cardMedia: {
    aspectRatio: "16/9",
    cursor: "pointer"
  },
  cardActions: {
    marginTop: "0.5rem"
  }
}))

interface WorksProps {
  loading: boolean
  works: Work[]
}

const initialWorkState: Work = {
  id: 0,
  title: "",
  year: 0,
  season: 0,
  image: "string",
  staffs: [],
  casts: [],
  twitterUsername: "",
  officialSiteUrl: "",
  mediaText: "",
  seasonNameText: "",
}

const Works: React.FC<WorksProps> = ({ loading, works}) => {
  const classes = useStyles()

  const [open, setOpen] = useState(false)
  const [work, setWork] = useState<Work>(initialWorkState)

  const handleOpen = () => {
    setOpen(true)
  }

  const handleClose = () => {
    setOpen(false)
  }

  return (
    <React.Fragment>
      <Grid container spacing={4}>
        <WorkDetail
          work={work}
          open={open}
          handleClose={handleClose}
        />
        {
          loading ? <CircularProgress className={classes.circularProgress}/>
            : works != null && works.length >= 1 && works.map((work) => (
              <Grid item key={work.id} xs={12} sm={6} md={4}>
                <Card className={classes.card}>
                  <CardMedia
                    component="img"
                    className={classes.cardMedia}
                    // 画像がなかった場合は「NO IMAGE」を表示(各自用意してpublicディレクトリ以下に配置)
                    src={work.image ? work.image : "/no_image.png"}
                    onError={(e: any) => {
                      e.target.src = "/no_image.png"
                    }}
                    onClick={() => {
                      handleOpen()
                      setWork(work)
                    }}
                  />
                  <CardActions className={classes.cardActions}>
                    {
                      work.seasonNameText != null && (
                        <Chip
                          label={work.seasonNameText}
                          variant="outlined"
                        />
                      )
                    }
                    {
                      work.mediaText != null && (
                        <Chip
                          label={work.mediaText}
                          variant="outlined"
                        />
                      )
                    }
                    {
                      work.officialSiteUrl != null && (
                        <Chip
                          label="公式サイト"
                          component="a"
                          rel="noopener noreferrer"
                          href={work.officialSiteUrl}
                          target="_blank"
                          clickable
                          color="secondary"
                          variant="outlined"
                        />
                      )
                    }
                    {
                      work.twitterUsername != null && (
                        <Chip
                          label="Twitter"
                          component="a"
                          rel="noopener noreferrer"
                          href={`https://twitter.com/${work.twitterUsername}`}
                          target="_blank"
                          clickable
                          color="primary"
                          variant="outlined"
                        />
                      )
                    }
                  </CardActions>
                  <CardContent>
                    <Typography variant="h3" gutterBottom>
                      {work.title}
                    </Typography>
                  </CardContent>
                </Card>
              </Grid>
            ))
        }
      </Grid>
    </React.Fragment>
  )
}

export default Works
./src/App.tsx
import React, { useEffect, useState } from "react"

import { makeStyles, ThemeProvider } from "@material-ui/core/styles"
import Container from "@material-ui/core/Container"

import Header from "components/layouts/Header"
import Works from "components/work/Works"
import SelectBox from "components/work/SelectBox"
import theme from "components/utils/theme"
import { getWorks } from "lib/api/works"
import { Work } from "interfaces/index"

const useStyles = makeStyles(() => ({
  container: {
    marginTop: "2rem"
  }
}))

const years: Array<{
  value: number
  label: string
}> = []

// 現在の年を取得
const currentYear: number = new Date().getFullYear()

for (var y = currentYear; y >= 1970; y--) {
  years.push({
    value: y,
    label: `${y}`
  })
}

const seasons: Array<{
  value: number
  label: string
}> = [
  { value: 1, label: "" },
  { value: 2, label: "" },
  { value: 3, label: "" },
  { value: 4, label: "" }
]

// 現在の季節を取得
const currentSeason: number = seasons[(Math.ceil((new Date().getMonth() +1 ) / 3)) - 2].value

const App: React.FC = () => {
  const classes = useStyles()

  const [loading, setLoading] = useState<boolean>(true)
  const [works, setWorks] = useState<Work[]>([])

  const handleGetWorks = async (year?: number, season?: number, title?: string): Promise<void> => {
    const res = await getWorks(year, season, title)

    if (res.status === 200) {
      setWorks(res.data.works)
    }

    setLoading(false)
  }

  // デフォルトでは現在の年・季節の作品を取得
  useEffect(() => {
    handleGetWorks(currentYear, currentSeason)
  }, [])
  
  return (
    <React.Fragment>
      <ThemeProvider theme={theme}>
        <Header handleGetWorks={handleGetWorks} setLoading={setLoading}/>
        <Container className={classes.container} maxWidth="lg">
          <SelectBox
            years={years}
            seasons={seasons}
            handleGetWorks={handleGetWorks}
            setLoading={setLoading}
          />
          <Works works={works} loading={loading}/>
        </Container>
      </ThemeProvider>
    </React.Fragment>
  )
}

export default App

動作確認

スクリーンショット 2021-07-24 3.42.15.png

最終的にこんな感じになっていれば完成です。

番外編(データベースの定期更新)

ここから先は番外編なので興味の無い人は読み飛ばしてOKです。

もし今回作成したアプリを本格的に使い続けたい場合、定期的に情報を更新するバッチ処理などを実装する必要があるでしょう。(アニメ作品はこれからも続々と追加されていくため)

そこで一応、データベースの定期更新について自分なりの手順を記しておきます。

sidekiqをインストール

sidekiq.png

今回はRailsアプリに定期実行を組み込む際に定番の sidekiq というgemを使っていきたいと思います。

./Gemfile
gem 'sidekiq'
gem 'sidekiq-cron'

Gemfileを更新したので再度ビルド。

$ docker-compose build

Workerクラスを作成

定期実行用のWorkerクラスを作成します。

$ docker-compose run api rails g sidekiq:worker Test
$ docker-compose run api rails g sidekiq:worker WorkImport
$ docker-compose run api rails g sidekiq:worker WorkDetailImport
./app/workers/test_worker.rb
class TestWorker
  include Sidekiq::Worker

  # 動作確認用
  def perform
    puts "Hello World!"
  end
end
./app/workers/work_import_worker.rb
class WorkImportWorker
  include Sidekiq::Worker

  # Annictから情報を取得
  def perform
    Work.new.import_from_annict
  end
end
./app/workers/work_detail_import_worker.rb
class WorkDetailImportWorker
  include Sidekiq::Worker

  # しょぼいカレンダーから情報を取得
  def perform
    WorkDetail.new.import_from_syobocal
  end
end

各種設定

$ touch config/initializers/sidekiq.rb config/sidekiq.yml config/schedule.yml
./config/initializers/sidekiq.rb
# Redisの設定
Sidekiq.configure_server do |config|
  config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379") }
end

Sidekiq.configure_client do |config|
  config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379")}
end

# どのタイミングで定期実行を行うかを記述したファイルを読み込む
schedule_file = "config/schedule.yml"

if File.exist?(schedule_file) && Sidekiq.server?
  Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file)
end
./config/sidekiq.yml
:verbose: false
:pidfile: ./tmp/pids/sidekiq.pid
:concurrency:  25
:queues:
  - default
./config/schedule.yml
test:
  cron: "*/5 * * * *" # 5分おきに実行
  class: "TestWorker"
  queue: default
work_import:
  cron: "0 0 * * 1" # 毎週月曜日の午前0時に実行
  class: "WorkImportWorker"
  queue: default
work_detail_import:
  cron: "0 0 * * 1" # 毎週月曜日の午前0時に実行
  class: "WorkDetailImportWorker"
  queue: default
./config/application.rb
# 以下3行を適当な場所に追記(sidekiqのダッシュボードを見るために必要)
# https://edgeguides.rubyonrails.org/api_app.html#using-session-middlewares
config.session_store :cookie_store, key: '_interslice_session'
config.middleware.use ActionDispatch::Cookies
config.middleware.use config.session_store, config.session_options
./config/routes.rb
require "sidekiq/web"
require "sidekiq/cron/web"

Rails.application.routes.draw do
  mount Sidekiq::Web, at: "/sidekiq" # ダッシュボードへのルーティング

  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]
      resources :works, only: %i[index]
    end
  end
end
./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 4306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
      REDIS_URL: redis://redis:6379 # 追記
    ports:
      - "3001:3000"
    depends_on:
      - db
  redis: # 追記
    image: redis:6.0-alpine
    volumes:
      - redis:/data
    command: redis-server --appendonly yes
  worker: # 追記
    build: .
    environment:
      RAILS_ENV: development
      REDIS_URL: redis://redis:6379
    volumes:
      - .:/myapp
    depends_on:
      - redis
    command: bundle exec sidekiq -C config/sidekiq.yml
volumes:
  mysql-data:
  redis: # 追記

動作確認

設定の変更を反映させるためにコンテナを再起動させます。

$ docker-compose down
$ docker-compose up -d

スクリーンショット 2021-07-24 5.38.02.png

http://localhost:3001/sidekiq にアクセスして良い感じのダッシュボードが表示されればOK。

スクリーンショット 2021-07-24 5.39.39.png

「cron」タブを開いてみると、先ほど作成した定期実行がスケジューリングされています。

$ docker-compose logs -f worker

worker_1  | 2021-07-23T20:37:18.682Z pid=1 tid=go7s74505 INFO: Cron Jobs - add job with name: work_detail_import
worker_1  | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Booted Rails 6.1.4 application in development environment
worker_1  | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Running in ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux]
worker_1  | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: See LICENSE and the LGPL-3.0 for licensing details.
worker_1  | 2021-07-23T20:37:20.715Z pid=1 tid=go7s74505 INFO: Upgrade to Sidekiq Pro for more features and support: https://sidekiq.org
worker_1  | 2021-07-23T20:40:07.738Z pid=1 tid=go7ssy92d class=TestWorker jid=be181a712bacc7ece7d0d7c7 INFO: start
worker_1  | Hello World!
worker_1  | 2021-07-23T20:40:10.093Z pid=1 tid=go7ssy92d class=TestWorker jid=be181a712bacc7ece7d0d7c7 elapsed=2.353 INFO: done
worker_1  | 2021-07-23T20:45:18.099Z pid=1 tid=go7sopr55 class=TestWorker jid=82dccac35bf6b3b56cd2d701 INFO: start
worker_1  | Hello World!
worker_1  | 2021-07-23T20:45:18.103Z pid=1 tid=go7sopr55 class=TestWorker jid=82dccac35bf6b3b56cd2d701 elapsed=0.004 INFO: done

「docker-compose logs」コマンドでログを確認し、5分おきに「Hello World!」と出力されれば無事動いていると考えて大丈夫です。その他も時が来ればしっかりと実行されるはず。

あとがき

以上、Annict様としょぼいカレンダー様の力を借りて俺流アニメデータベースを作ってみました。

やはり自分で作ったアプリというのは愛着が湧くものなので、今後視聴するアニメを選ぶ際などに利用したいと思います。

41
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
33

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?