はじめに
本日(12/25(日))はクリスマスの休日ですが皆様いかがお過ごしでしょうか?
コロナ禍で皆で集まるのもまだまだ難しい状況かとは思いますが、年末年始の休暇に向けて多人数で遊べるゲームがほしくないですか?
ほしいですよね!?
そこで以下のようなキーワードでググってみることにしました。
が、検索結果には以下の様な課題があり、どうにも欲しいものを探すことができませんでした。
- 見たことがあるゲームしか出てこない
- 4人用ではないものも検索される
仕方がないので初心にかえって任天堂の公式サイトを見てみるも、やはり4人用のゲームを探す方法は分かりませんでした。
エンジニアとしてはないものは作ればいいのでは?ということで何か作れないか考えることにしました。
ゲーム一覧の取得
ゲームの一覧が取得できれば、そこから4人用のゲームを探すことはできそうです。
ということで、APIがないか探してみることにしました。
何かいろいろヒットしますが、公式のAPIはなさそうです。
ただ、xml 形式で公開しているデータはありそうでした。
これを見ると5872本ものゲームがリリース(予定含む)されていることが分かります。(2022/12/25時点)
宝の山にしか見えないですね!
xml ではなく、json で取得したかったので、api を利用することにしました。
- APIでゲーム一覧取得
// ゲーム一覧取得
async function getGames() {
const games = await getGamesJapan();
fs.writeFileSync(OUT_FOLDER + 'games.json', JSON.stringify(games), 'utf-8');
return games;
}
4人用ゲームの抽出 : 問題編
残念なことに、取得できたゲーム一覧の情報には、プレイ人数が含まれていません。
公式のAPIは存在しないため、プレイ人数をきれいな形で取得するのは難しそうです。
タイトルは分かったので、5872回『商品をさがす』から検索して、ページをチクチク開いて確認すれば目的は達成できそうです。
ただ、楽しい年末年始のためとはいえエンジニアがやる作業ではなさそうです。
もはやこれまででしょうか…
4人用ゲームの抽出 : 解決編1
よくよく見るとという項目があります。
<LinkURL>/titles/{何かのID}</LinkURL>
一方、My Nintendo Store のURLは以下の様な形式です。
このページには「プレイ人数」という項目があります。
https://store-jp.nintendo.com/list/software/{何かのID}.html
ページをザクザク開いて、その部分を抽出できれば4人用ゲームだけ抽出することができそうです。
// got で page を取得し、cheerio で解析
const response = await got(gameUrl(game));
const $ = cheerio.load(response.body);
// プレイ人数記載部分を解析し、key, value に格納 (ページによって記載方法が違うためとりあえず)
const _players = $('table.productDetail--spec tr.productDetail--spec__player')
_players.each((i, e) => {
players.push({
key: $('th', e).text(),
value: $('td', e).text()
});
});
4人用ゲームの抽出 : 問題編2
驚くことに、プレイ人数にはとてもたくさんの種類がありました。
1人
2人
1〜2人
1〜3人
2〜3人
1〜4人
2〜4人
4人
1〜5人
2〜5人
5人
2〜6人
1〜6人
4〜6人
2〜7人
1〜8人
2〜8人
5〜8人
4〜8人
8人
1〜9人
2〜9人
1〜10人
2〜10人
1〜12人
2〜12人
1〜14人
2〜14人
1〜15人
4〜15人
1〜16人
1〜20人
2〜20人
1〜30人
4〜32人
1〜32人
2〜35人
1〜42人
1〜55人
1〜60人
60人
1〜64人
1〜70人
1〜99人
1〜100人
1〜200人
1〜999人
4人用ゲームの抽出 : 解決編2
厳密な4人用ではなくても4人以上で遊べればよいので、見落としなくすべてのソフトを確認したいですし、さらにはキーワードやタグなんかも指定して検索したくなってきました。
検索結果にヒットしたゲームをすべて購入するわけにもいきません。検索結果をクリックしたら、eshop にいってそのまま詳細を見れたほうがいいでしょう。
ということでそんな感じのUIを作ることにしました。
UI制作には Nuxt.js + Vuetify を利用しました。もはやローコードと言っていい程度のコード量で制作できました。
(Vue.js 単体でもよいのですが、Vuetify を使う設定が面倒だったので Nuxt.js を利用しました。)
<template>
<div>
<v-row>
<v-col cols="2" class="teal lighten-5">
<v-text-field
v-model="searchTitle"
label="タイトル"
prepend-icon="mdi-magnify"
hide-details
dense
/>
<v-autocomplete
v-model="selectedMaker"
:items="makers"
label="メーカー"
chips
small-chips
clearable
dense
class="mt-8"
/>
<v-autocomplete
v-model="selectedCategories"
:items="categories"
label="カテゴリ"
multiple
chips
small-chips
deletable-chips
clearable
dense
class="mt-8"
/>
<v-autocomplete
v-model="selectedPlayers"
:items="players"
label="プレイ人数"
multiple
chips
small-chips
deletable-chips
clearable
dense
class="mt-8"
/>
<v-select
v-model="selectedMinPrice"
:items="PRICES"
label="価格(MIN)"
clearable
dense
class="mt-8"
/>
<v-select
v-model="selectedMaxPrice"
:items="PRICES"
label="価格(MAX)"
clearable
dense
class="mt-4"
/>
</v-col>
<v-col cols="10">
<v-data-table
:headers="gameHeaders"
:items="filteredGames"
:footer-props="{
'items-per-page-options': [ 200 ]
}"
:ites-per-page="200"
height="calc(100vh - 192px)"
fixed-header
dense
>
<template #[`item.TitleName`]="{ item }">
<div class="d-flex __clickable" @click="onShowPage(item)">
<v-img
:src="`/images/eshop/games/${item.InitialCode}.jpg`"
max-width="172"
/>
<div>
<div class="text-h6 pa-1">
{{ item.TitleName }}
</div>
<div class="mt-2">
<v-chip
v-for="(c, i) in item._categories"
:key="i"
x-small
class="mx-1 mb-1"
@click.stop="onClickCategory(c)"
>
{{ c }}
</v-chip>
</div>
</div>
</div>
</template>
<template #[`item._players`]="{ item }">
<div
v-for="(p, i) in item._players"
:key="i"
class="d-flex"
>
<div class="__players__key">
{{ p.key }}
</div>
<div class="__players__value">
{{ p.value }}
</div>
</div>
</template>
</v-data-table>
</v-col>
</v-row>
</div>
</template>
<script>
import GAMES from '~/assets/eshop/games.json'
import DETAILS from '~/assets/eshop/index.js'
const BASE_URL = 'https://store-jp.nintendo.com/list/software/'
// 最大プレイ人数を取得
function maxPlayerCount (v) {
const counts = v.replace('人', '').split('〜').map(v => Number(v)).filter(v => !isNaN(v))
const count = counts.reduce((a, v) => Math.max(a, Number(v)), 0)
return count
}
export default {
name: 'GameList',
data: () => {
const PRICES = ['500', '1000', '1500', '2000', '3000', '4000', '5000']
const games = GAMES
.map((g) => {
const detail = DETAILS[g.InitialCode]
g.TitleName = String(g.TitleName)
g._price = Number(g.Price.replace(/[^0-9a-z]/gi, ''))
g._categories = detail.categories
g._players = detail.players
return g
})
const makers = games.map(g => g.MakerName).sort()
const categories = Array.from(new Set(games.flatMap(g => g._categories))).sort()
const players = Array.from(new Set(games.flatMap(g => g._players).map(p => p.value))).sort((v0, v1) => {
// 最大プレイ人数順でソート
return maxPlayerCount(v0) - maxPlayerCount(v1)
})
return {
PRICES,
games,
makers,
categories,
players,
searchTitle: '',
selectedMaker: null,
selectedCategories: [],
selectedPlayers: [],
selectedMinPrice: '',
selectedMaxPrice: '',
selectedGame: null
}
},
computed: {
gameHeaders () {
return [
{ text: 'タイトル', value: 'TitleName' },
{ text: 'メーカー', value: 'MakerName' },
{ text: '価格', value: '_price', align: 'end' },
{ text: '発売日', value: 'SalesDate', align: 'end' },
{ text: 'プレイ人数', value: '_players' }
]
},
filteredGames () {
let games = this.games
if (this.selectedMaker) {
games = games.filter(g => this.selectedMaker === g.MakerName)
}
if (this.selectedCategories.length > 0) {
games = games.filter(g => g._categories.some(c => this.selectedCategories.includes(c)))
}
if (this.selectedPlayers.length > 0) {
games = games.filter(g => g._players.some(p => this.selectedPlayers.includes(p.value)))
}
if (this.selectedMinPrice !== '') {
const price = Number(this.selectedMinPrice)
games = games.filter(g => g._price >= price)
}
if (this.selectedMaxPrice !== '') {
const price = Number(this.selectedMaxPrice)
games = games.filter(g => g._price <= price)
}
if (this.searchTitle !== '') {
games = games.filter(g => ~g.TitleName.indexOf(this.searchTitle))
}
return games
},
showPageUrl () {
if (!this.selectedGame) {
return ''
}
return BASE_URL + this.selectedGame.LinkURL.replace('/titles/', '') + '.html'
}
},
methods: {
onShowPage (game) {
this.selectedGame = game
window.open(this.showPageUrl, '_blank')
},
onClickCategory (c) {
if (!this.selectedCategories.includes(c)) {
this.selectedCategories.push(c)
}
}
}
}
</script>
<style lang="scss" scoped>
.__clickable {
cursor: pointer;
}
.__players__key {
width: 144px;
}
.__players__value {
width: 60px;
}
</style>
サムネイル画像について
xml に含まれている ScreenshotImgURL
をそのまま使うことはできません。
画像への直リンクは禁止されていますし、サムネで表示するには大きすぎる画像なので、リサイズして保存することにしました。
// screen shot を取得してリサイズして保存
const res = await axios.get(game.ScreenshotImgURL, { responseType: 'arraybuffer' });
const thumbnail = await sharp(res.data)
.resize(320)
.jpeg()
.toBuffer();
fs.writeFileSync(file, thumbnail, 'binary');
まとめ
とても短いコードで4人用ゲームを探せる様になりました。
個人で利用できればと思いましたが、他の方にも需要あるかと思い本稿を起稿しました。
年末年始のゲームライフ・プログラミングライフの充実の一助となれれば幸いです。
また、switch は素晴らしいインディーズゲームがたくさん出ているプラットフォームだと思います。
これらのゲームが人目に触れず埋もれるのはもったいないので色々見てもらえたらいいなと思います。
おまけ
また、本稿のプログラムは github にプログラム学習リソースとして公開しています。
よろしければ合わせてご参照ください。
※本稿のプログラムは、あくまで個人で楽しむ範囲でご利用ください。
SESビジネスプログラマのためのプログラミング練習帳/03_switch_eshop