AWS Lambda を利用して Nexus 6 を (σ´∀`)σゲッツ!! できないんだよ・・・


AWS Lambda を利用して Nexus 6 を (σ´∀`)σゲッツ!! - yanoの日記

そうか・・・Google Play 君・・・君は、アクセス元のIPアドレスから国を自動割り出しその国の在庫を出してくれるんだね・・・・
そして AWS Lambda 君・・・君は cron トリガーがないばかりか東京リージョンもないんだね・・・・お手本のようなプレビュー機能じゃあないか・・・

というわけで、上記の記事では (σ´∀`)σゲッツ!! できないことが分かりました(日本の在庫確認ができない)
じゃあ Proxy 通せば?とか、もうやめましょう。普通に作ろうじゃあないか。
ポテンシャルは Azure のモバイルサービス、ジョブサービスを超えていると思うが、現時点では Lambda は Azure をたたきのめせていない、棲み分けになっている。
規模の経済により Amazon の方が Azure より有利ではあるが、Azure, Google も追い上げてきているので(特に Azure)危機感を持って今後に期待したいところだ。

ということで国内サーバーより Google Play にアクセスし在庫状況を取得し、AWS SDK for JavaAmazon SNS に Push 通知するようにした。
技術者でない普通のユーザは Android 限定になってしまうが以下のアプリを使うとよいと思う。

Play Store Stock Checker : Google Playストアの端末販売状況を確認できるアプリ、在庫や価格の変動をプッシュ通知する機能も搭載 | juggly.cn

投げやり感満載ではあるが、書いたコードの断片を残しておこうと思う。
一応、GCM(Google Cloud Messaging) と APNS, default で iOS, Android, Eメール に通知を送ることが可能だ。
スタンドアロンJava ではなく Jetty で動かして cron で head 送るようにしたが、その辺は好きにするといいだろう。

public class NotifyNexus6StockServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;

    private static final int MAX_PUSH_DEFAULT_LENGTH = 70; // iOS は 8 以降ペイロードの許容サイズが飛躍的に増えたから、もっと多くしても大丈夫

    private static final AtomicBoolean blue32GBHasNotified = new AtomicBoolean(false);

    private static final AtomicBoolean blue64GBHasNotified = new AtomicBoolean(false);

    private static final AtomicBoolean white32GBHasNotified = new AtomicBoolean(false);

    private static final AtomicBoolean white64GBHasNotified = new AtomicBoolean(false);

    private static final Map<String, Object> shortenData(final Map<String, Object> data) {
        // default を短縮したバージョンの data クローンを作る
        final Map<String, Object> shortData = new LinkedHashMap<String, Object>();

        shortData.putAll(data);
        if (shortData.containsKey("default")) {
            final Object defaultMsg = shortData.get("default");

            if (defaultMsg instanceof CharSequence) {
                shortData.put("default", Strings.shorten(defaultMsg.toString(), MAX_PUSH_DEFAULT_LENGTH)); // 長すぎる文字列を切り詰める
            }
        }

        return shortData;
    }

    public static final String makeASNSJSON(final Map<String, Object> data, final boolean wantContentAvailable, final boolean wantBadge, final boolean wantSound) {
        final Map<String, Object> shortData = shortenData(data);
        final Map<String, Object> payload = new LinkedHashMap<String, Object>();

        { // iPhone
            final Map<String, Object> apns = new LinkedHashMap<String, Object>();
            final Map<String, Object> aps = new LinkedHashMap<String, Object>();

            if (wantContentAvailable) {
                aps.put("content-available", Integer.valueOf(1));
            }
            aps.put("alert", shortData.get("default")); // THE・流用

            if (wantBadge) {
                if (shortData.containsKey("badge")) {
                    final Object badge = shortData.get("badge");

                    if (badge instanceof Number) {
                        aps.put("badge", Integer.valueOf(((Number) badge).intValue()));
                    }
                }
            }
            if (wantSound) {
                if (shortData.containsKey("sound")) {
                    final Object sound = shortData.get("sound");

                    if (sound instanceof CharSequence) {
                        aps.put("sound", sound.toString());
                    } else {
                        aps.put("sound", "default");
                    }
                } else {
                    aps.put("sound", "default");
                }
            }

            apns.putAll(shortData);
            apns.remove("badge");
            apns.remove("sound");
            apns.put("aps", aps);
            payload.put("APNS", JSON.encode(apns));
        }

        { // Android(GCM)
            final Map<String, Object> gcm = new LinkedHashMap<String, Object>();

            gcm.put("data", shortData);
            payload.put("GCM", JSON.encode(gcm));
        }

        { // default
            if (shortData.containsKey("default")) {
                final Object defaultMsg = shortData.get("default");

                if (defaultMsg instanceof CharSequence) {
                    payload.put("default", defaultMsg.toString());
                }
            }
        }

        return JSON.encode(payload);
    }

    protected static boolean isInStock(final String url) throws IOException {
        final DefaultHttpClient client = new DefaultHttpClient();

        try {
            final HttpGet request = new HttpGet(url + "&_=" + new Date().getTime()); // get なのでクエリパラメータ付けてキャッシュ抑止しましょう
            final HttpResponse response = client.execute(request);
            final String content = BaseClient.getResponse(response);

            return StringUtils.contains(content, "詳しくはお近くの携帯電話ショップまでお問い合わせください。") && !StringUtils.contains(content, "現在在庫切れです。しばらくしてからもう一度ご確認ください。");
        } catch (final HttpHostConnectException e) {
            return false;
        } finally {
            client.getConnectionManager().shutdown();
        }
    }

    protected static void publishTopic(final String defaultMsg) {
        final ClientConfiguration clientConfiguration = new ClientConfiguration();
        final AmazonSNSClient client = new AmazonSNSClient(new BasicAWSCredentials("XXXXXXXXXXXXXXXXXXXX", "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"), clientConfiguration);

        client.setEndpoint("sns.us-east-1.amazonaws.com"); // FIXME: リージョンは適宜変更

        try {
            try {
                final String targetArn = "arn:aws:sns:us-east-1:XXXXXXXXXXXX:NotifyMe";
                final Map<String, Object> data = new LinkedHashMap<String, Object>();

                data.put("default", defaultMsg);
                data.put("badge", Integer.valueOf(1)); // バッジは好きにすればいいと思う

                final String message = makeASNSJSON(data, true, true, true);

                if (StringUtils.isBlank(message)) {
                    return;
                }

                final PublishRequest publishRequest = new PublishRequest().withTargetArn(targetArn).withMessage(message).withMessageStructure("json");

                client.publish(publishRequest);
            } catch (final Exception e) {
                e.printStackTrace();
            }
        } finally {
            client.shutdown();
        }
    }

    @Override
    final protected void service(final HttpServletRequest req, final HttpServletResponse resp) throws IOException {
        if (!blue32GBHasNotified.get()) {
            if (isInStock("https://play.google.com/store/devices/details/Nexus_6_32_GB_%E3%83%80%E3%83%BC%E3%82%AF%E3%83%96%E3%83%AB%E3%83%BC?id=nexus_6_blue_32gb")) {
                publishTopic("Nexus 6(blue/32gb) is now available!");
                blue32GBHasNotified.set(true);
            }
        }

        if (!blue64GBHasNotified.get()) {
            if (isInStock("https://play.google.com/store/devices/details/Nexus_6_64_GB_%E3%83%80%E3%83%BC%E3%82%AF%E3%83%96%E3%83%AB%E3%83%BC?id=nexus_6_blue_64gb")) {
                publishTopic("Nexus 6(blue/64gb) is now available!);
                blue64GBHasNotified.set(true);
            }
        }

        if (!white32GBHasNotified.get()) {
            if (isInStock("https://play.google.com/store/devices/details/Nexus_6_32_GB_%E3%82%AF%E3%83%A9%E3%82%A6%E3%83%89_%E3%83%9B%E3%83%AF%E3%82%A4%E3%83%88?id=nexus_6_white_32gb")) {
                publishTopic("Nexus 6(white/32gb) is now available!");
                white32GBHasNotified.set(true);
            }
        }

        if (!white64GBHasNotified.get()) {
            if (isInStock("https://play.google.com/store/devices/details/Nexus_6_64_GB_%E3%82%AF%E3%83%A9%E3%82%A6%E3%83%89_%E3%83%9B%E3%83%AF%E3%82%A4%E3%83%88?id=nexus_6_white_64gb")) {
                publishTopic("Nexus 6(white/64gb) is now available!");
                white64GBHasNotified.set(true);
            }
        }
    }
}

え?状態の永続化?大丈夫大丈夫!!(そこまで必要ないでしょ・・・)
(しかも static に状態持たせちゃうと分散環境かつ非スティッキーなやけに凝った運用をしていると複数通知されてしまいますがその辺はご了承)