シリーズ卑しい小銭稼ぎのためにGatsbyでAmazonアソシエイトのリンクをいい感じに変換したい第2回、前回の記事でContentfulのエントリー中のiframelyの埋め込みタグを素のURLに置き換えたので、今回はビルド時に素のURLをリッチな表示になるように変換するようにフロント側をいじります。
今回はgatsby-remark-embedderのcustomTransformersを利用します。これを利用することで変換したいURLと変換後のHTMLを渡すだけで今回やりたいことを実現できます。
Amazonのリンク表示に欲しい情報を今回はAmazonのProduct Advertising API(以下PA-API)で取得します。これにより様々な情報を取得できますが、今回必要な情報は以下の3つです。
今回はASINから商品情報を取りたいので利用するオペレーションはGetItemsです。
試しにリクエストを飛ばしたいときにAmazonが公開しているScratchpadが便利です。
今回はGetItemsを使います。
Common parametersに必要な値を入れていきます。Marketplaceはwww.amazon.co.jp
、Partner TypeはAssociates
です。Partner Tagは使用するアソシエイトタグ(日本なら末尾-22)を入力します。Access KeyとSecret KeyはAmazonのアソシエイトツールバーから認証情報を作成して入手できます。
Request parameteresのItemIdsにASINが入ります。Resourcesで返して欲しい情報を選択します。今回は商品名ItemInfo.Title
、中サイズの商品画像Images.Primary.Medium
、価格Offers.Listings.Price
を選択します。Run requestでリクエストを飛ばします。
JSON responseで返ってくるjsonを確認できます。返す情報を絞ってリクエストを送れるのは便利ですね。
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)
});
大丈夫ですね。gatsby-remark-embedderでAmazonのURLを変換する際にPA-APIを叩いて必要な情報を取得すれば良さそうです。
とはいえ、約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形式で扱うことにしました。
YAMLに保存するために次のことをやります。
yamlの読み書きはjs-yamlを使用します。js-yamlに関してはYaml ファイルを読み書きする (js-yaml) | まくまくNode.jsノートがとてもわかりやすかったです。
npm install js-yaml
予想通りAPIを叩くときの非同期処理に苦しみましたが、何とかうまくタイマーも実装できました。PA-APIのToo Many Requestsの条件はよくわからず、エラーが出た際に売上をあげないと解除されないなどの怪情報も一部見られたため、かなりビビって設定したのが1分です。以下、コード全文です。ルートディレクトリから実行すれば、あとは1分おきにちゃんと取得されるのを脇にゲーム配信をぼんやり眺めます。
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日あたりの上限に達したのではないかと思います。
幸い、残り数個だったので残りはScratchpadから手動で取得することにしました。これで既存の記事の商品情報をローカルで読み込むことができるようになりました。
ちなみに価格の取得日時とamazonのURLも必要になったので、後で適当に追加しました。
ちなみに、もう一つ警戒すべきエラーにItemNotAccessible
があります。どうも一部の商品はPA-APIのデータベースに登録されていないらしいです。エラーハンドリングでこの場合にWebページからfetchするようにしてもよさそうですが、数は多くなかったので今回は手動で調達しました。
参考: Amazon Product Advertising API - Item Not Accessible - Stack Overflow
いよいよ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を更新する処理を追加しておきます。
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を読み込むよう設定します。
// ...
{
resolve: `gatsby-remark-embedder`,
options: {
customTransformers: [
amazonLinkTransformer
],
services: {
Instagram: {
accessToken: process.env.INSTAGRAM_ACCESS_TOKEN,
},
},
},
},
// ...
ローカルでビルドして正常に動作することを確認できたので、Githubにコミットします。GithubのSecretsにAmazon関係の環境変数の追加も忘れずにすることが必要です。Contentful上の単純なamazonのURLがビルドされるとこんな表示になります。
このブログはGithub ActionsでビルドしてNetlifyにホスティングしている構成です。ローカルでビルドすればローカルのYAMLが更新されるのでそれをGithubにコミットすればレポジトリのYAMLも更新されるのですが、Contentfulで記事を更新した際のビルドでレポジトリ上のYAMLも更新されないと面倒です。
ビルド用のworkflowである.github/workflows/build.yml
のsteps以下に変更をコミットするjobを追加します。利用するGithub ActionはEndBug/add-and-commit@v6です。
# 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: 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
にすれば大丈夫なようです。
うまくいきました。これでとりあえず求めていた機能を実装できました。Gatsby楽しいですね。