{{% amazon <ASIN> %}}で、以下のようにアマゾンの商品を表示をするshortcodeを作った。

DVD

マンガ

キングダム 46 (ヤングジャンプコミックス)

著者:原 泰久

出版社:集英社

その他

ちなみに{{% amazon <ASIN> %}}のようにshortcodeを解釈しないようにエスケープするには

{{%/* amazon <ASIN> */%}}

の様に書く1。バッククォートで囲んで、この表示ができなかったので引用にした。

ちなみにこの引用部分は

> \{\{%/* amazon &lt;ASIN> */%}}

のように書いている。

閑話休題。

shortcodeにする意味は単に短く書けるということだけでなく、通常用とAMP用の出し分けができるということだ。通常用はlayouts/shortcodes/*.htmlに、AMP用はlayouts/shortcodes/*.amp.htmlに書けば、それを使ってくれる。 AMPでは<amp-img>を使うなど、使えるタグが通常と違うので、この対応は必要。

ちなみに、hugo_theme_robustというテーマはAMPに対応している。

作るにあたって、HugoでAmazonの商品紹介用のShortcodesを作ってみた | backportをだいぶ参考にさせてもらった。

APIを作る

Hugo - Data-driven ContentにあるようにJSONを返すAPIなら

{{ $dataJ := getJSON "url" }}

でデータを取ってきて、その値を使うことができるがAmazonのAPIはJSONに対応してないので、JSONを返すAPIを自前で作る必要がある。

これも上のサイトにPHPを使ってAmazonの商品情報をJSON形式で取得 | backportという記事があったので参考にした。

記事では一つのファイルで作っていたが、Slimの勉強もかねて、Slimで作った。

Slimはskeletonをまず作成すると良さそう。

$ php composer.phar create-project slim/slim-skeleton [my-app-name]

これでSlimのskeletonが作られる。事前にSlimの何かをダウンロードしておく必要はない。起動するには

$ cd [my-app-name]; php -S 0.0.0.0:8080 -t public public/index.php

する。-tはドキュメントルートを指定するオプション。

skeletonと構造が違うが、First Application Walkthrough - Slim Frameworkを見ると大体の使い方がわかる。

作ったのはこちら

基本的な機能を作る

Skeletonを修正して基本的な機能を作る。

説明。

.gitignoreを

 /vendor/
 /logs/*
 !/logs/README.md
+/src/settings_secret.php

のように変更した。src/settings_secret.phpにはamazon apiをリクエストするときに使うSecret Keyなどの設定を書く。 こんな感じだ。

<?php
return [
    'AWSAccessKeyId' => '****',
    'AWSSecretKey' => '****',
    'AWSAssociateTag' => '****',
];

AWSAccessKeyIdとAWSSecretKeyはProduct Advertising APIから作れる。

AWSAssociateTagはAmazonアソシエイトのタグでsanrinsha-22みたいなやつだ。

src/settings_secret.phpsrc/settings.phpから読み込む。

<?php
+$amazon = require __DIR__ . '/settings_secret.php';
+
 return [
     'settings' => [
         'displayErrorDetails' => true, // set to false in production
         'addContentLengthHeader' => false, // Allow the web server to send the content-length header
 
-        // Renderer settings
-        'renderer' => [
-            'template_path' => __DIR__ . '/../templates/',
-        ],
+        'amazon' => $amazon,
 
         // Monolog settings
         'logger' => [

rendererはいらないので消した。

composer.jsonにAmazon Product Advertising APIのライブラリであるexeu/apai-ioを追加した。

         "php": ">=5.5.0",
         "slim/slim": "^3.1",
         "slim/php-view": "^2.0",
-        "monolog/monolog": "^1.17"
+        "monolog/monolog": "^1.17",
+        "exeu/apai-io": "~2.0"
     },
     "require-dev": {
         "phpunit/phpunit": ">=4.8 < 6.0"

src/dependencies.phpにはapai-ioのLookupクラスとApaiIDクラスのオブジェクトを$containerに入れる処理を追加した。

 <?php
 // DIC configuration
 
+use ApaiIO\Configuration\GenericConfiguration;
+use ApaiIO\Operations\Lookup;
+use ApaiIO\ApaiIO;
+
 $container = $app->getContainer();
 
-// view renderer
-$container['renderer'] = function ($c) {
-    $settings = $c->get('settings')['renderer'];
-    return new Slim\Views\PhpRenderer($settings['template_path']);
+$container['lookup'] = function ($c) {
+    return new Lookup();
+};
+
+$container['apaiio'] = function ($c) {
+    $client = new \GuzzleHttp\Client();
+    $request = new \ApaiIO\Request\GuzzleRequest($client);
+
+    $conf = new GenericConfiguration();
+    $conf
+        ->setCountry('co.jp')
+        ->setAccessKey($c['settings']['amazon']['AWSAccessKeyId'])
+        ->setSecretKey($c['settings']['amazon']['AWSSecretKey'])
+        ->setAssociateTag($c['settings']['amazon']['AWSAssociateTag'])
+        ->setRequest($request);
+
+    return new ApaiIO($conf);
 };

アクセスキーなどの設定をここでしている。リクエストによって変わらない設定はここでやっておけばいいのかな。

src/routes.phpはこんな感じに変更した。

 <?php
 // Routes
 
-$app->get('/[{name}]', function ($request, $response, $args) {
-    // Sample log message
-    $this->logger->info("Slim-Skeleton '/' route");
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7;
 
-    // Render index view
-    return $this->renderer->render($response, 'index.phtml', $args);
+$app->get('/asin/{asin}', function ($request, $response, $args) {
+    $asin = $request->getAttribute('asin');
+    $this->lookup->setItemId($asin);
+    $this->lookup->setResponseGroup(['Images', 'Small']);
+
+    try {
+        $res = $this->apaiio->runOperation($this->lookup);
+    } catch(RequestException $e) {
+        $this->logger->error(Psr7\str($e->getRequest()));
+        $this->logger->error(Psr7\str($e->getResponse()));
+        return $response->withJson([
+            "error" => [
+                "request" => Psr7\str($e->getRequest()),
+                "response" => Psr7\str($e->getResponse()),
+            ]
+        ], $e->getCode());
+    }
+
+    $results = simplexml_load_string($res);
+
+    if (!(bool)$results->Items->Request->IsValid) {
+        $message = "Request is invalid. (ASIN: $asin)";
+        $this->logger->error($message);
+        return $response->withJson([
+            "error" => [
+                "message" => $message
+            ]
+        ], 400);
+    } elseif (empty($results->Items->Item[0])) {
+        $message = "Item is not found. (ASIN: $asin)";
+        $this->logger->error($message);
+        return $response->withJson([
+            "error" => [
+                "message" => $message
+            ]
+        ], 404);
+    }
+
+    $item = $results->Items->Item[0];
+
+
+    return $response->withJson([
+        "asin"=> (string) $item->ASIN,
+        "title"=> (string) $item->ItemAttributes->Title,
+        "author" => (string) $item->ItemAttributes->Author,
+        "manufacturer" => (string) $item->ItemAttributes->Manufacturer,
+        "item_url"=> (string) $item_url = $item->DetailPageURL,
+        "image_url"=> (string) $item->MediumImage->URL,
+    ]);
 });

部分ごとに説明していく。

$app->get('/asin/{asin}', function ($request, $response, $args) {
    $asin = $request->getAttribute('asin');
    $this->lookup->setItemId($asin);
    $this->lookup->setResponseGroup(['Images', 'Small']);

まず、最初の部分では/asin/{asin}のエンドポイントでリクエストを受けてASINを取得している。次の部分ではItemLookup APIを使う設定をしている。

ItemLookup APIのAmazonのドキュメントはこちら
apai-ioのドキュメントはこちら
Response Groupsについてはこちら

    try {
        $res = $this->apaiio->runOperation($this->lookup);
    } catch(RequestException $e) {
        $this->logger->error(Psr7\str($e->getRequest()));
        $this->logger->error(Psr7\str($e->getResponse()));
        return $response->withJson([
            "error" => [
                "request" => Psr7\str($e->getRequest()),
                "response" => Psr7\str($e->getResponse()),
            ]
        ], $e->getCode());
    }

の部分でAPIの実行と例外処理をしている。

        $this->logger->error(Psr7\str($e->getRequest()));

でログを書きこんでいる。書き込み先の設定はsrc/settings.phpにかかれていて、初期設定ではlogs/app.logになっている。

        return $response->withJson([
            "error" => [
                "request" => Psr7\str($e->getRequest()),
                "response" => Psr7\str($e->getResponse()),
            ]
        ], $e->getCode());

でJSONのレスポンスを返している。第一引数はJSONとして返す配列、第二引数はHTTPステータスコードになる。省略すると200が返る。withJson()についてはResponse - Slim Frameworkを参照。

また、apai-ioは内部でGuzzleを使っているが、Guzzleの例外コードには、失敗したリクエストのHTTPステータスコードが入っているので、ここではamazon apiのレスポンスのHTTPステータスコードをそのまま返している。

そのあとは、リクエスト方法が間違っていた場合や、itemが見つからなかった場合の処理をしている。

そして、最後の部分で必要な情報をJSONで返している。

リトライ処理をする

amazon APIは数回に1回位の頻度で失敗するのでリトライ処理を入れる。for文ではなくGuzzleのMiddlewareを使って処理する。これについてはGuzzleのMiddleware::retryを使ってみた - QiitaGuzzle 6 retry middleware – Addshoreを参考にした。

$deciderでリトライするかの判断をする。リトライするときはtrueをかえす。コネクションのエラーや500系エラーの場合はリトライをする。リトライ上限を超えた場合、500系以外のエラー(400系は自分側の問題)、成功した場合はリトライしない。

$delayでは遅延時間を返す。

$decider$delayMiddleware::retryに渡して、$handerStackにpushする。

$client = new Client(['handler' => $handlerStack, 'timeout' => 5]);

handlertimeout設定している。

shortcodeを作る

layouts/shortcodes/amazon.html

{{ $itemId := index .Params 0 }}
{{ $json := getJSON "http://0.0.0.0:8080/asin/" $itemId }}

<div class="amazon-shortcode-box">
  <div class="amazon-shortcode-image">
    <a href="{{ $json.PageURL }}" name="amazon-shortcode" target="_blank">
      <img src="{{ $json.ImageURL }}"
      alt="{{ $json.ItemAttributes.Title }}"
        width="{{ $json.ImageWidth }}"
        height="{{ $json.ImageHeight }}"></img>
    </a>
  </div>
  <div class="amazon-shortcode-info">
    <p class="amazon-shortcode-title">
      <a href="{{ $json.PageURL }}" name="amazon-shortcode" target="_blank">
        {{ $json.ItemAttributes.Title }}
      </a>
    </p>
    {{ with $json.ItemAttributes.Author }}
      <p class="amazon-shortcode-detail">著者:{{ . }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Director }}
      <p class="amazon-shortcode-detail">監督:{{ . }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Actor }}
      <p class="amazon-shortcode-detail">主演:{{ delimit . ", " }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Manufacturer }}
      {{ if or ( eq $json.ItemAttributes.ProductGroup "Book") (eq $json.ItemAttributes.ProductGroup "eBooks") }}
        <p class="amazon-shortcode-detail">出版社:{{ . }}</p>
      {{ else }}
        <p class="amazon-shortcode-detail">{{ . }}</p>
      {{ end }}
    {{ end }}
  </div>
</div>

layouts/shortcodes/amazon.amp.html

{{ $itemId := index .Params 0 }}
{{ $json := getJSON "http://0.0.0.0:8080/asin/" $itemId }}

<div class="amazon-shortcode-box">
  <div class="amazon-shortcode-image">
    <a href="{{ $json.PageURL }}" name="amazon-shortcode" target="_blank">
      <amp-img src="{{ $json.ImageURL }}"
        alt="{{ $json.ItemAttributes.Title }}"
        width="{{ $json.ImageWidth }}"
        height="{{ $json.ImageHeight }}"></amp-img>
    </a>
  </div>
  <div class="amazon-shortcode-info">
    <p class="amazon-shortcode-title">
      <a href="{{ $json.PageURL }}" name="amazon-shortcode" target="_blank">
        {{ $json.ItemAttributes.Title }}
      </a>
    </p>
    {{ with $json.ItemAttributes.Author }}
      <p class="amazon-shortcode-detail">著者:{{ . }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Director }}
      <p class="amazon-shortcode-detail">監督:{{ . }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Actor }}
      <p class="amazon-shortcode-detail">主演:{{ delimit . ", " }}</p>
    {{ end }}
    {{ with $json.ItemAttributes.Manufacturer }}
      {{ if or ( eq $json.ItemAttributes.ProductGroup "Book") (eq $json.ItemAttributes.ProductGroup "eBooks") }}
        <p class="amazon-shortcode-detail">出版社:{{ . }}</p>
      {{ else }}
        <p class="amazon-shortcode-detail">{{ . }}</p>
      {{ end }}
    {{ end }}
  </div>
</div>

layouts/partials/styles.cssまたはcustom.cssに以下を追加

/***** Amazon Shortcode *****/
div.amazon-shortcode-box {
  border: 1px solid #cccccc;
  border-radius: 2px;
  margin: 0.5rem;
  padding: 0.5rem;
  display: flex;
}

div.amazon-shortcode-box > div.amazon-shortcode-info {
  padding: 0 0 0 0.5rem;
}

div.amazon-shortcode-info > p.amazon-shortcode-title {
  margin: 0 0 0.5rem 0;
}

div.amazon-shortcode-info > p.amazon-shortcode-detail {
  margin: 0;
  font-size: 0.8rem;
  line-height: 1.2rem
}

起動用のシェルスクリプトを作る

#!/usr/bin/env bash
set -ex

theme=hugo_theme_robust

cd "$(dirname "${BASH_SOURCE:-$0}")"

# 全部削除して綺麗にする
shopt -s extglob
rm -r public/!(CNAME) || :

# amazonの商品情報を返すAPIの起動
pushd /path/to/amazon-json-api
php -S 0.0.0.0:8080 -t public public/index.php &
trap "kill $!" EXIT
popd

hugo --theme="$theme"