初歩からの無職

GatsbyでAmazonのリンクを変換する

  • GatsbyJS
  • Node.js
  • JavaScript

シリーズ卑しい小銭稼ぎのためにGatsbyでAmazonアソシエイトのリンクをいい感じに変換したい第2回、前回の記事でContentfulのエントリー中のiframelyの埋め込みタグを素のURLに置き換えたので、今回はビルド時に素のURLをリッチな表示になるように変換するようにフロント側をいじります。

今回はgatsby-remark-embeddercustomTransformersを利用します。これを利用することで変換したいURLと変換後のHTMLを渡すだけで今回やりたいことを実現できます。

Amazon Product Advertising API 5.0

GetItemsでASINから商品情報を取得できる

Amazonのリンク表示に欲しい情報を今回はAmazonのProduct Advertising API(以下PA-API)で取得します。これにより様々な情報を取得できますが、今回必要な情報は以下の3つです。

  • 商品名
  • 商品画像
  • 価格

今回はASINから商品情報を取りたいので利用するオペレーションはGetItemsです。

Scratchpadを使ってみる

試しにリクエストを飛ばしたいときにAmazonが公開しているScratchpadが便利です。

scratchpad-1

今回はGetItemsを使います。

scratchpad-2

Common parametersに必要な値を入れていきます。Marketplaceはwww.amazon.co.jp、Partner TypeはAssociatesです。Partner Tagは使用するアソシエイトタグ(日本なら末尾-22)を入力します。Access KeyとSecret KeyはAmazonのアソシエイトツールバーから認証情報を作成して入手できます。

scratchpad-3

Request parameteresのItemIdsにASINが入ります。Resourcesで返して欲しい情報を選択します。今回は商品名ItemInfo.Title、中サイズの商品画像Images.Primary.Medium、価格Offers.Listings.Priceを選択します。Run requestでリクエストを飛ばします。

scratchpad-4

JSON responseで返ってくるjsonを確認できます。返す情報を絞ってリクエストを送れるのは便利ですね。

amazon-paapiを使ってみる

PA-API5.0のNode.js用のSDKがあります。今回はそのwrapperのamazon-paapiを使います。

npm install amazon-paapi

GetItemsのサンプルをScratchpadと同じパラメータをもたせて走らせると同じような結果が返ってくることがわかります。

require("dotenv").config({ path: `.env.development` }); // dev環境の環境変数を読み込み
const amazonPaapi = require('amazon-paapi');

const commonParameters = {
  AccessKey: process.env.AMAZON_ACCESS_KEY,
  SecretKey: process.env.AMAZON_SECRET_KEY,
  PartnerTag: process.env.AMAZON_PARTNER_TAG,
  PartnerType: process.env.AMAZON_PARTNER_TYPE,
  Marketplace: "www.amazon.co.jp",
};

const requestParameters = {
    'ItemIds'   : ['B00X9CDPE4'],
    'ItemIdType': 'ASIN',
    'Condition' : 'New', 
    'Resources' : [
        'Images.Primary.Medium', 
        'ItemInfo.Title',
        'Offers.Listings.Price'
        ]
};

/** Promise */
amazonPaapi.GetItems(commonParameters, requestParameters)
    .then(data => {
        // do something with the success response.
        console.log(JSON.stringify(data));
    })
    .catch(error => {
        // catch an error.
        console.log(error)
    });

getItems-result

大丈夫ですね。gatsby-remark-embedderでAmazonのURLを変換する際にPA-APIを叩いて必要な情報を取得すれば良さそうです。

APIのリクエストを抑えるために取得済み情報をYAMLで保存する

とはいえ、約100個のAmazon Linkがあるため、ビルドするたびに一斉に100個のAPIリクエストを飛ばすのはなんともお行儀が悪く、実際にToo Many Requestsエラーで弾かれてしまいます。そのため、一度取得したものをローカルに保存しておいて、ローカルファイルにない場合にのみAPIを叩くようにします。このあたりの振る舞いはGatsbyJSで作っているブログでリッチなリンクを貼れるようにした | キクナントカドットコムを参考にしました。

データをストレージする形式として昔はJSONだったと思うのですが、先の参考リンク先のようにYAMLを使うことも多いみたいです。GatsbyのガイドでもSourcing Content from JSON or YAML | Gatsbyでも、深い意味はないかもしれないですがJSONに先んじてYAMLを紹介しています。GithubActionsのworkflowもyamlで書かれていたりで、何より触ったことなかったのでYAML形式で扱うことにしました。

ContentfulのエントリーからASINを取得して商品情報をYAMLに保存する

YAMLに保存するために次のことをやります。

  • ContentfulのContent Management APIでエントリーからASINリストを作成
  • amazon-paapiを1分おきに叩く(Too Many Requests対策)
  • オブジェクトをYAMLに保存する

yamlの読み書きはjs-yamlを使用します。js-yamlに関してはYaml ファイルを読み書きする (js-yaml) | まくまくNode.jsノートがとてもわかりやすかったです。

npm install js-yaml

予想通りAPIを叩くときの非同期処理に苦しみましたが、何とかうまくタイマーも実装できました。PA-APIのToo Many Requestsの条件はよくわからず、エラーが出た際に売上をあげないと解除されないなどの怪情報も一部見られたため、かなりビビって設定したのが1分です。以下、コード全文です。ルートディレクトリから実行すれば、あとは1分おきにちゃんと取得されるのを脇にゲーム配信をぼんやり眺めます。

/contentful/get-asin.js
require("dotenv").config({ path: `.env.development` });
const fs = require("fs");
const yaml = require("js-yaml");
const amazonPaapi = require("amazon-paapi");
const env = process.env;

process.on("unhandledRejection", console.dir);

const contentful = require("contentful-management");
const client = contentful.createClient({
  accessToken: env.CONTENTFUL_PERSONAL_ACCESS_TOKEN,
});

const getAsinList = async () => {
  /*
    contentfulのBlog PostにあるすべてのASINのリストを返す
   */
  const space = await client.getSpace(env.CONTENTFUL_SPACE_ID);
  const environment = await space.getEnvironment("master");
  const entries = await environment.getEntries({
    content_type: "blogPost",
    order: "-sys.createdAt",
    query: "https://www.amazon.co.jp/",
  });

  let asinList = [];
  for (let item of entries.items) {
    const re = /(?<=https:\/\/www.amazon.co.jp\/exec\/obidos\/ASIN\/).*?(?=\/mtane0412-22\/)/g;
    if (item.fields.body.ja.match(re)) {
      const asins = item.fields.body.ja.match(re);
      asinList = asinList.concat(asins);
    }
  }
  const result = asinList.filter((x, i, self) => {
    return self.indexOf(x) === i;
  });

  return result;
};

const amazonFetch = (asin) => {
  /*
    Amazon PA-APIで商品情報を返す
  */
  const commonParameters = {
    AccessKey: env.AMAZON_ACCESS_KEY,
    SecretKey: env.AMAZON_SECRET_KEY,
    PartnerTag: env.AMAZON_PARTNER_TAG,
    PartnerType: env.AMAZON_PARTNER_TYPE,
    Marketplace: "www.amazon.co.jp",
  };

  const requestParameters = {
    ItemIds: [asin],
    ItemIdType: "ASIN",
    Condition: "New",
    Resources: [
      "Images.Primary.Medium",
      "ItemInfo.Title",
      "Offers.Listings.Price",
    ],
  };

  return amazonPaapi
    .GetItems(commonParameters, requestParameters)
    .then((data) => {
      const item = data.ItemsResult.Items[0];
      const title = item.ItemInfo.Title.DisplayValue;
      const imageUrl = item.Images.Primary.Medium.URL;
      const price = item.Offers
        ? item.Offers.Listings[0].Price.DisplayAmount
        : "N/A";
      return { title, imageUrl, price };
    })
    .catch((error) => {
      console.log(error);
    });
};

(async () => {
  const asinList = await getAsinList();
  let counter = asinList.length;

  const amazonList = {};
  const myFunction = async () => {
    const asin = asinList[counter - 1];
    amazonList[asin] = await amazonFetch(asin);

    counter--;

    if (counter > 0) {
      console.log(counter);
      console.log(amazonList[asin]);
      setTimeout(myFunction, 60000);
    } else {
      const yamlText = yaml.dump(amazonList);
      fs.writeFile("./content/amazon-list.yaml", yamlText, "utf8", (err) => {
        if (err) {
          console.error(err.message);
          process.exit(1);
        }
        console.log("success!: amazon-list.yaml");
      });
    }
  };
  myFunction();
})();

順調に進んでいたのですが残り5個くらいというところでToo Many Requestsが返されるようになりました。時間あたり、もしくは1日あたりの上限に達したのではないかと思います。

TooManyRequests

amazonListYaml

幸い、残り数個だったので残りはScratchpadから手動で取得することにしました。これで既存の記事の商品情報をローカルで読み込むことができるようになりました。

ちなみに価格の取得日時とamazonのURLも必要になったので、後で適当に追加しました。

ItemNotAccessible

ちなみに、もう一つ警戒すべきエラーにItemNotAccessibleがあります。どうも一部の商品はPA-APIのデータベースに登録されていないらしいです。エラーハンドリングでこの場合にWebページからfetchするようにしてもよさそうですが、数は多くなかったので今回は手動で調達しました。

参考: Amazon Product Advertising API - Item Not Accessible - Stack Overflow

gatsby-remark-embedderのCustom Transformerを書く

いよいよgatsby-remark-embedderのCustom Transformerを用意します。customTransformersは3つのプロパティを持っている必要があります

  • getHTML(url, options): 与えられたURLがtransformerと一致したときにHTMLを返す
  • name: サービス名。servicesオプションでkeyとして使われる
  • shouldTransform(url): 与えられたURLが変換対象のURLと一致するかチェックしてbooleanを返す

getHTMLの処理にcontent/amazon-list.yamlにASINがあるかチェックして、ない場合にAPIを叩いて、更にYAMLを更新する処理を追加しておきます。

/src/utils/amazon-link-transformer.js
require("dotenv").config({
  path: `.env.${process.env.NODE_ENV}`,
});
const fs = require("fs");
const yaml = require("js-yaml");
const moment = require("moment");

const amazonPaapi = require("amazon-paapi");
const regex = /https:\/\/www\.amazon\.co\.jp\/exec\/obidos\/ASIN\/(.*?)\/.*?-22\//;
const amazonList = yaml.safeLoad(
  fs.readFileSync("content/amazon-list.yaml"),
  "utf8"
);

const html = ({ title, imageUrl, price, url, updatedAt }) => {
  const updatedAtYYYYMMDD = moment(updatedAt).format("YYYY年MM月DD日");
  if (title && imageUrl && price) {
    return `
    <div style="display: flex; background-color: #fff; padding: 1em; margin: 1em; border: 1px solid #ccc;align-items: center; flex-wrap: wrap;">
      <div style="min-width: 160px; min-height: 160px; text-align: center; flex: 1;">
        <a href="${url}" target="_blank" rel="noopener"><img src="${imageUrl}" loading="lazy" alt="${title}" /></a>
      </div>
      <div style="display: flex; flex-direction: column; align-content: center; padding: 1em; flex: 9;">
        <span style="padding: .5em; font-weight: bold; font-size: 1.25em;"><a href="${url}" target="_blank" rel="noopener">${title}</a></span>
        <span style="padding: .5em; font-weight: bold; color: red;">${price} <small style="margin-left: 1em;font-weight: normal;color: #666;">(${updatedAtYYYYMMDD}時点)</small></span>
        <span style="padding: .5em;"><a href="${url}" target="_blank" rel="noopener">Amazon.co.jp</a></span>
      </div>
    </div>
  `;
  } else {
    /* 情報が欠けているときは変換しない */
    return `${url}`;
  }
};

const updateAmazonListYaml = async (amazonList) => {
  fs.writeFile(
    "content/amazon-list.yaml",
    yaml.dump(amazonList),
    "utf8",
    (err) => {
      if (err) {
        console.error(err.message);
        process.exit(1);
      }
      console.log("updated: amazon-list.yaml");
    }
  );
};

const amazonFetch = async (url, asin) => {
  /* PA-APIから商品情報をamazonListに登録する */
  const commonParameters = {
    AccessKey: process.env.AMAZON_ACCESS_KEY,
    SecretKey: process.env.AMAZON_SECRET_KEY,
    PartnerTag: process.env.AMAZON_PARTNER_TAG,
    PartnerType: process.env.AMAZON_PARTNER_TYPE,
    Marketplace: "www.amazon.co.jp",
  };
  const requestParameters = {
    ItemIds: [asin],
    ItemIdType: "ASIN",
    Condition: "New",
    Resources: [
      "Images.Primary.Medium",
      "ItemInfo.Title",
      "Offers.Listings.Price",
    ],
  };

  return amazonPaapi
    .GetItems(commonParameters, requestParameters)
    .then((data) => {
      console.log(`${url}の商品情報を取得します`);
      const updatedAt = new Date();
      const item = data.ItemsResult.Items[0];
      const title = item.ItemInfo.Title.DisplayValue;
      const imageUrl = item.Images.Primary.Medium.URL;
      const price = item.Offers
        ? item.Offers.Listings[0].Price.DisplayAmount
        : "N/A";
      return (amazonList[asin] = {
        title,
        imageUrl,
        price,
        url,
        updatedAt,
      });
    })
    .catch((error) => {
      /* エラーをキャッチした場合、urlと日付だけamazonListに登録する */
      console.log(`${url}の変換に失敗しました`);
      console.log(error);
      return (amazonList[asin] = {
        title: null,
        imageUrl: null,
        price: null,
        url,
        updatedAt,
      });
    });
};

const getHTML = async (url) => {
  const asin = await url.replace(regex, "$1");
  if (asin in amazonList === false) {
    /* ASINが登録されていない場合、amazonListに登録する */
    try {
      await amazonFetch(url, asin); // amazonListに商品情報登録
      await updateAmazonListYaml(amazonList); // amazon-list.yamlを更新
    } catch (err) {
      console.error(err.message);
    }
  }

  try {
    return html(amazonList[asin]);
  } catch (err) {
    /* どうしてもダメなときは変換しない */
    return `${url}`;
  }
};

const name = "amazon";

const shouldTransform = (url) => regex.test(url);

module.exports = { getHTML, name, shouldTransform };

gatsby-config.jsでcustom transformerを読み込むよう設定します。

gatsby-config.js
// ...
{
  resolve: `gatsby-remark-embedder`,
    options: {
      customTransformers: [
        amazonLinkTransformer
      ],
        services: {
          Instagram: {
            accessToken: process.env.INSTAGRAM_ACCESS_TOKEN,
          },
        },
    },
},
// ...

ローカルでビルドして正常に動作することを確認できたので、Githubにコミットします。GithubのSecretsにAmazon関係の環境変数の追加も忘れずにすることが必要です。Contentful上の単純なamazonのURLがビルドされるとこんな表示になります。

amazon-link-transformer-result

Github Actionsでレポジトリ上のYAMLを更新する

このブログはGithub ActionsでビルドしてNetlifyにホスティングしている構成です。ローカルでビルドすればローカルのYAMLが更新されるのでそれをGithubにコミットすればレポジトリのYAMLも更新されるのですが、Contentfulで記事を更新した際のビルドでレポジトリ上のYAMLも更新されないと面倒です。

ビルド用のworkflowである.github/workflows/build.ymlのsteps以下に変更をコミットするjobを追加します。利用するGithub ActionはEndBug/add-and-commit@v6です。

.github/workflows/build.yml
# steps:
# ビルドのjob以降
 - name: Commit changes on amazon-list.yaml
   uses: EndBug/add-and-commit@v6
   with:
     add: 'content/amazon-list.yaml'
     author_name: 'mtane0412'
     author_email: mtane0412@gmail.com
     message: 'add new amazon link data'
     cwd: '.'

これでビルド時にcontent/amazon-list.yamlに更新があればレポジトリのyamlも更新してくれるはずです。が、なにやらうまくきません。

error-add-and-commit

Error: could not read Username for 'https://github.com': No such device or address · Issue #68 · EndBug/add-and-commitに答えがありました。actions/checkout@v1を使っている場合はactions/checkout@v2にすれば大丈夫なようです。

success-add-and-commit

add-new-amazon-link-data

うまくいきました。これでとりあえず求めていた機能を実装できました。Gatsby楽しいですね。