概要
自分はアニメを見るのが趣味です。昨今のコロナ事情によってリモートワークが基本となった事もあり、以前よりもアニメに没頭する機会が増えました。
毎日のように「今期のアニメで面白そうな作品は無いかなぁ」なんて探しているわけですが、どうもアニメの情報って効率的に取得しづらい気がしています。
もちろん、世の中にはたくさんのアニメ情報サイトが存在しているものの、自分にとっては必要無い情報がたくさん羅列されていたりしてしっくり来ない事もしばしば。
たとえば、私が視聴するアニメを選ぶ基準としては、
- どんなスタッフが携わっているか
- どんな声優さんが出演されているか
- キャラデザインは自分好みか
- 世間的な注目度は高そうか
といったものが主な判断材料となっています。
要するに、製作陣やキャスト陣、キービジュアルやSNSのフォロワー数などが一目でわかれば情報としてはそれなりに十分というわけですね。
そこで今回は、↑の要件を満たすアプリを自分で作ってみる事にしました。
完成イメージ
- 年代・季節ごとに作品を絞り込み
- 作品のタイトルで個別に検索
- 作品のイメージ画像
- 製作陣やキャスト陣の情報一覧
- 公式サイトやTwitterアカウントへのリンク
必要最低限な機能・情報がコンパクトにまとまっていると思います。
主な使用技術・サービス
- バックエンド
- Ruby
- Rails API
- MySQL
- フロントエンド
- React
- TypeScript
- 外部サービス
※ 再現性を考慮してバックエンドのみDockerで環境構築を行います。
Annict、しょぼいカレンダーともにアニメ好きであれば一度は利用した事があるのではないでしょうか。簡単な情報からマニアックな情報まで網羅的に掲載してくれている素晴らしいWebサービスです。
それぞれAPIを公開しているため、素直にそれらを使えば良いんじゃねって思われるかもしれませんが、どちらも個人的には痒いところにあと一歩届かない感があったので、色々こねくり回して扱いやすい形に整形するためバックエンドを準備しました。
実装
前置きはほどほどに実装を開始しましょう。
バックエンド
先にバックエンド側から。
環境構築
何はともあれ環境構築を行います。
各種ディレクトリ・ファイルを作成
$ mkdir aninfo-backend && cd aninfo-backend
$ touch Dockerfile docker-compose.yml entrypoint.sh Gemfile Gemfile.lock
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"]
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:
#!/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 "$@"
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
gem "rails", "~> 6"
# 空欄でOK
rails new
APIモードで作成します。
$ docker-compose run api rails new . --force --no-deps -d mysql --api
database.ymlを編集
デフォルトの状態だとデータベースとの接続ができないので「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 にアクセス
localhost:3001 にアクセスして初期状態の画面が表示されればOKです。
gemをインストール
後々の処理で必要になるgemをインストールしておきます。
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
- title
- WorkDetail(作品の詳細)※ しょぼいカレンダーから取得する情報
- work_id
- Workモデルとの関連付け用
- staffs
- 製作陣
- casts
- キャスト陣
- syobocal_tid
- しょぼいカレンダーのTID
- work_id
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
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を利用するためのアクセストークンが必要になるので、公式ドキュメントの手順に従い事前に取得しておいてください。
アクセストークンが取得できたら、ルートディレクトリに「.env」ファイルを作成してそこに環境変数としてセットします。
$ touch .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
...
最終的にこんな感じでそれぞれの情報が格納されていれば成功です。(※ 古い作品などは空欄になってしまう箇所多し)
APIを作成
データベースに格納した情報をJSON形式で返すAPIを作成します。
コントローラー
$ docker-compose run api rails g controller api/v1/works
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)をプラスして情報の網羅性を高めています。
ルーティング
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があるのでそちらをインストールしましょう。
gem 'rack-cors'
APIモードで作成している場合、すでにGemfile内に記載されているのでコメントアウトを外すだけでOKです。
$ docker-compose build
Gemfileを更新したので再度ビルド。
cors.rbを編集
「config/initializers/」に設定ファイルが存在するはずなので、外部からアクセス可能なように編集しておきます。
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行を追記してください。
{
"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」を次のように変更します。
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")
)
import React from "react"
const App: React.FC = () => {
return (
<h1>Hello World!</h1>
)
}
export default App
一旦、動作確認してみましょう。
$ yarn start
localhost:3000 にアクセスして「Hello World!」と返ってくればOK。
型定義
プロジェクト全体で使い回す事になるであろう型(今回であればWork)を「./src/interfaces/index.ts」の中に記述しておきます。
$ mkdir src/interfaces
$ touch 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経由で受け取るレスポンスの値をスネークケース→キャメルケースに変換、または送信するリクエストの値をキャメルケース→スネークケースに変換してくれるライブラリ
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」というライブラリを使わせてもらっています。
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}`)
}
}
REACT_APP_RAILS_API_BASE_URL=http://localhost:3001
※ Reactで環境変数を使用する場合、環境変数名の先頭にREACT_APP_を付ける必要があるので注意。
動作確認
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
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
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
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
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
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
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
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
動作確認
最終的にこんな感じになっていれば完成です。
番外編(データベースの定期更新)
ここから先は番外編なので興味の無い人は読み飛ばしてOKです。
もし今回作成したアプリを本格的に使い続けたい場合、定期的に情報を更新するバッチ処理などを実装する必要があるでしょう。(アニメ作品はこれからも続々と追加されていくため)
そこで一応、データベースの定期更新について自分なりの手順を記しておきます。
sidekiqをインストール
今回はRailsアプリに定期実行を組み込む際に定番の sidekiq というgemを使っていきたいと思います。
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
class TestWorker
include Sidekiq::Worker
# 動作確認用
def perform
puts "Hello World!"
end
end
class WorkImportWorker
include Sidekiq::Worker
# Annictから情報を取得
def perform
Work.new.import_from_annict
end
end
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
# 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
:verbose: false
:pidfile: ./tmp/pids/sidekiq.pid
:concurrency: 25
:queues:
- default
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
# 以下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
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
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
http://localhost:3001/sidekiq にアクセスして良い感じのダッシュボードが表示されればOK。
「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様としょぼいカレンダー様の力を借りて俺流アニメデータベースを作ってみました。
やはり自分で作ったアプリというのは愛着が湧くものなので、今後視聴するアニメを選ぶ際などに利用したいと思います。