⚒️

My Nintendo Store で目当ての商品が入荷したらLINEに通知するシステムを作った

2022/12/03に公開1

こちらはApplibot Advent Calendar 2022 3日目の記事になります。

はじめに

こんにちは。ゲームのバックエンドエンジニアをしているFirstSSです。

最近のNintendo SwitchはポケモンSVやスプラトゥーン3などの有名タイトルが発売されてかなり盛り上がっていますね。
以前はSwitch本体の在庫薄が続いていましたが、最近は安定して入手できるようです。その一方で、Proコントローラー(以下、プロコン)に関しては未だに品薄気味...
私もスプラ3の発売を控えた少し前の日にプロコンの調子が悪くなり、その上どこに行ってもなかなか購入できず、公式のMy Ninteno Storeでサイレント入荷した情報を観測してもすぐに売り切れてしまい途方に暮れていました。

そこで、エンジニアなら仕組みで解決しようじゃないかということで構築したのが今回のシステムになります。

構成

  1. 一定の間隔でCloud SchedulerがPub/Subのtopicにメッセージをキューイングさせる
  2. Pub/Subがメッセージを発行し、それをトリガーにCloud Functionsの関数を実行
  3. 実行した関数がMy Nintendo Storeの指定したページをスクレイピング
  4. 入荷していればLINEに通知

LINEに通知する設定

まず、Cloud Functionsに設定するトークンを入手する作業をします。

  1. LINE Notify にログインします。

https://notify-bot.line.me/my/

  1. [トークンを発行する]を選択し、好きなトークン名とどのトークルームに通知したいかを選択します。

  2. [発行する]を選択し発行されたトークンをコピーしておきます。後でCloud Functionsを作成する際に使用します。

Cloud Pub/Sub の設定

  1. コンソールのトピックページに移動し、[トピックを作成]を選択します。
  2. 名前を決め、デフォルトの設定で作成します。

Cloud Scheduler の設定

  1. コンソールから[ジョブを作成]を選択します。
    unix-cron形式で実行する頻度を設定します。今回は1分おきにしています。
  1. ターゲットをPub/Subにし、先ほど作成したtopicを選択します。
  2. 設定を確認して[作成]を選択します。

Cloud Functions の設定

  1. コンソールから[関数を作成]を選択します。
  2. トリガーのタイプをPub/Subにし、先ほど作成したtopicを選択します。
  3. [ランタイム、ビルド、接続、セキュリティの設定]からランタイム環境変数として以下のように設定します。値には先ほど発行したLINE Notifyのトークンを入れます。
  4. [保存]->[次へ]を選択します。
  5. ランタイムにGo 1.16を選択し、プログラムを記述します。
    エントリポイントに使用したい関数名を指定します。今回はCheckとしています。
  6. [デプロイ]を選択します。
  7. 成功したら、関数が実行されてログが吐かれていることを確認しておきましょう。

実際に使用するソースコードは以下になります。

function.go
// Package p contains a Pub/Sub Cloud Function.
package p

import (
	"bytes"
	"context"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"golang.org/x/net/html/charset"

	"github.com/PuerkitoBio/goquery"
	"github.com/saintfish/chardet"
)

// PubSubMessage is the payload of a Pub/Sub event. Please refer to the docs for
// additional information regarding Pub/Sub events.
type PubSubMessage struct {
	Data []byte `json:"data"`
}

var layout = "2006-01-02 15:04:05"
var urlList = []string{
	// スプラ3エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKT.html",
	// モンハンエディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKQ.html",
	// スマブラエディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKE.html",
	// ゼノブレイド2エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKD.html",
	// スプラ2エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKB.html",
	// 通常版
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKA.html",
}

// Cloud Functions のグローバル変数は再利用される
var lastNotifiedDate string

// init runs during package initialization. So, this will only run during
// an instance's cold start.
func init() {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Println(err)
		return
	}
	
	lastNotifiedDate = time.Now().In(jst).Format(layout)
}

// Check consumes a Pub/Sub message.
func Check(ctx context.Context, m PubSubMessage) error {
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		return err
	}
	now := time.Now().In(jst)
	var t time.Time
	t, err = time.Parse(layout, lastNotifiedDate)
	if err != nil {
		return err
	}

	// 最後に入荷通知をしてから1時間以内なら、何もしない
	if now.Sub(t).Hours() < 1 {
		log.Println("最終入荷通知時刻: ", lastNotifiedDate)
		return nil
	}

	for _, url := range urlList {
		var text string
		text, err = getText(url)
		if err != nil {
			return err
		}

		log.Println(text, url)

		if strings.Contains(text, "カートに入れる") {
			log.Println("入荷!!", url)
			if err = sendMessage(url); err != nil {
				return err
			}
			lastNotifiedDate = now.Format(layout)
		}
	}

	return nil
}

func getText(url string) (string, error) {
	// Getリクエストでレスポンス取得
	res, err := http.Get(url)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()

	// Body内を読み取り
	buffer, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return "", err
	}

	// 文字コードを判定
	detector := chardet.NewTextDetector()
	detectResult, err := detector.DetectBest(buffer)
	if err != nil {
		return "", err
	}

	// 文字コードの変換
	bufferReader := bytes.NewReader(buffer)
	reader, err := charset.NewReaderLabel(detectResult.Charset, bufferReader)
	if err != nil {
		return "", err
	}

	// HTMLをパース
	document, err := goquery.NewDocumentFromReader(reader)
	if err != nil {
		return "", err
	}

	return document.Find(".productDetail--buttons__button--primary").Text(), nil
}

func sendMessage(message string) error {
	accessToken := os.Getenv("ACCESS_TOKEN")

	URL := "https://notify-api.line.me/api/notify"

	u, err := url.ParseRequestURI(URL)
	if err != nil {
		return err
	}

	c := &http.Client{}

	form := url.Values{}
	form.Add("message", message)

	body := strings.NewReader(form.Encode())

	req, err := http.NewRequest("POST", u.String(), body)
	if err != nil {
		return err
	}

	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Authorization", "Bearer "+accessToken)

	if _, err = c.Do(req); err != nil {
		return err
	}

	return nil
}

監視したい商品ページのURLを指定する

お目当ての商品ページのURLは、ご自身のケースに合わせて編集してください。

var urlList = []string{
	// スプラ3エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKT.html",
	// モンハンエディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKQ.html",
	// スマブラエディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKE.html",
	// ゼノブレイド2エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKD.html",
	// スプラ2エディション
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKB.html",
	// 通常版
	"https://store-jp.nintendo.com/list/hardware-accessory/controller/HAC_A_FSSKA.html",
}

グローバル変数を利用する

Cloud Functions において、グローバル変数を定義しておくと関数の実行の度に初期化されず、前回までの値が保存されるようです。これを利用して最終入荷通知時刻を保持しています。

// Cloud Functions のグローバル変数は再利用される
var lastNotifiedDate string

https://cloud.google.com/functions/docs/bestpractices/tips?hl=ja#use_global_variables_to_reuse_objects_in_future_invocations

無駄な通知を防ぐ

以下の部分で同じ通知が何度もきてしまうのを防いでいますが、売り切れるまでは通知し続けたい場合はこの部分を消すなど、ここもご自身のケースに合わせて編集してください。

// 最後に入荷通知をしてから1時間以内なら、何もしない
if now.Sub(t).Hours() < 1 {
	log.Println("最終入荷通知時刻: ", lastNotifiedDate)
	return nil
}

「カートに入れる」の文字列を検知する

購入ページでは商品の在庫によって、ボタンのテキストが「品切れ」か「カートに入れる」のどちらかになります。
また、「カートに入れる」の時はボタンのクラス名が.productDetail--buttons__button--primaryになるため、その差分で入荷を検知しています。

if strings.Contains(text, "カートに入れる") {
	log.Println("入荷!!", url)
	if err = sendMessage(url); err != nil {
		return err
	}
	lastNotifiedDate = now.Format(layout)
}
return document.Find(".productDetail--buttons__button--primary").Text(), nil

結果

ここまでできたら完成になります。あとは実際に入荷するのを待つだけです。
うまく動いていればLINEに通知が来るのを確認できます。

正常に動作しているか不安な場合は、現在在庫がある商品を商品ページのURLリストに追加して、すぐに通知がくることを確認すると良いと思います。

さいごに

ここまで読んでいただきありがとうございました!
Applibot Advent Calendar 2022 4日目の記事はkaz2ngtさんです!

Discussion

NicolasNicolas

なぜかできないです。
コードはうまく行っているみたいですが、動作確認のため在庫のあるurlを入れてもlineでメールが届きません。
何が正常に動作をしていないか確認したいのですが、エラーも出ずただlineが届かないだけです。
問題点を特定する方法はありますか?よろしければ返事をください。