皆様どうも、こんにちは!
こまりのソフトウェアエンジニア、桑木です。
今回、楽天に出店しているショップ関係者の依頼で、いわゆる外部カートを作成することになりました。ここでいう外部カートとは、楽天の商品ページ以外のサイトから楽天のカートに任意の商品を追加する機能のことです。要は「カートに商品を入れる」ボタンを楽天以外に設置したい、ということです。
今回の外部カートに要求される仕様として、一度の操作で複数商品をカートに追加したい、というものがありました。しかし、ログインしているかどうか、楽天市場アプリかブラウザか、またそれらの設定はどうか、といった状況に応じて出来たり出来なかったりします。結論としては「1種類の商品なら問題なくカートに入れられる。それ以上の動作も制限つきながら不可能ではないが、確実性は保証できない」ということになります。
この記事では、カートに追加する処理に関係する周辺技術を軽く紹介しつつ、なぜ出来ないのか、なぜ出来るのかといったことを説明していきたいと思います。
なお今回のタイトルは、伊藤清徳氏の記事「楽天の注文フォームに無茶させるver.2.1 スマホ対応 – 伊藤清徳の垂直落下式ムーンサルトプレス」をオマージュさせていただきました。
この記事の内容
概要
概要という名の調査結果を先に書いておきます。技術的に詳しい方はここだけ読めば問題ありません。
楽天はブラウザのJavaScriptをオフにしていても買い物ができます。この時に商品ページからカートに追加する処理はform要素によるPOSTで実装されています。
このPOSTのレスポンスに含まれるSet-CookieにはSameSite属性が指定されていないので、Lax
のデフォルト化にともなってクロスドメインでの利用が制限されてしまいました。
このPOST先のURLは今の所GETでも動作するので、ページ移動をともなうGETであればLax
の制限を回避できます。
ちなみに、POSTの場合の文字コードはEUC-JPですが、GETの場合はUTF-8です。
また、JavaScriptがオンの時は別のURLとのajaxな通信でカートに追加されますが、こちらも同じくLax
で制限されてしまいます。ページ移動しないajaxではどうやってもLax
の制限を回避できないので、今回調査した範囲では前述の方法が唯一確実な方法です。
そもそも楽天で外部カートは可能なのか
この記事では色々と外部カートについて述べますが、最初に大前提として一番重要なことを言っておきます。それは、楽天が公式に用意している方法はない、ということです。商品検索などその他の機能であれば楽天が公開しているAPIはありますが、外部カートに関してはAPIは存在しません。
その上でどうしても外部カートを実装するなら、いわゆるプライベートAPIを使用することになります。プライベートAPIとは、外部に仕様を公開せずにそのサービス自身が内部で使用しているAPIのことです。他者からの使用を想定していないので、予告なしに突然仕様が変わったりAPIそのものがなくなる可能性があります。
それを踏まえた上で外部カートの方法を検索してみると、数年前の記事だったりはしますがそれなりに検索にヒットします。それらの方法は今でも使えそうなので、将来的に仕様変更で動作しなくなるリスクはあるものの、短期的に見れば不可能ではない、と捉えても良さそうです。
2種類のプライベートAPI
面倒なので具体的な方法は解説しませんが、早い話、人間が操作してカートに追加した時と同じ通信をすれば良いだけです。
商品ページを観察してみると、今どきのajaxな方法と昔ながらのフォームでPOSTする方法の2種類のAPIがあるようです。HTTPリクエストという観点からはURLが違うだけでどちらも似たようなものですが、レスポンスに違いが出てきます。
ajaxな方法
実際に商品ページでカートに追加ボタンを押してみると、ページはそのままで操作の結果が吹き出しで表示されます。
パラメータをx-www-form-urlencodedでPOSTすればJSON形式のレスポンスが返ってくるという非常に一般的な形式のAPIです。
JSON形式のレスポンスはスクリプトでとても扱いやすい形式なので、可能ならこちらのAPIを使いたいところです。
フォームによるPOST
さすがに歴史の長い楽天なだけあって、ブラウザのJavaScript機能をオフにしてもお買い物できます。
この時の通信に利用されるのは、form要素のsubmitによるPOSTです。こちらは最終的にカートページへリダイレクトされます。
もちろん、商品ページと同じようにform要素からsubmitするとページ移動してしまうので、こちらのAPIであっても複数商品入れるには結局ajaxな方法(fetchなど)を取らざるを得ないのかな、と思います。
どちらのAPIも必要な値は同じ
パラメータ名が微妙に違ったり追加のパラメータがあったりはしますが、どちらのAPIでもショップID, 商品ID, 選択肢別在庫ID, 選択肢文字列, 数量をパラメータで指定するということは同じようです。
ショップIDと商品IDは色々なところで見かけますが、選択肢別在庫ID(inventory_id
)の値は他で見かけたことがないので、これは実際の商品ページから抽出する必要がありました。結局、全て商品ページのフォーム要素の値を参考にすれば良いということですね。
どちらのAPIもリクエストはx-www-form-urlencodedでPOSTする方式です。フォームPOSTの方は文字コードがEUC-JPだったりしますが、これについては後述します。
ドメインが違う問題
ところでJavaScript界隈では有名な仕様ですが、ブラウザで実行されるJavaScriptには同一生成元ポリシー(SOP)という制限があります。Nodeやら何とかmonkeyみたいな制限の緩いJavaScriptをやっていると存在を忘れがちですが、これはスクリプトが動作しているページと通信先などのドメイン(オリジン)が違うと、状況に応じてその動作をブラウザがブロックするという仕組みです。
幸いなことに今回外部カートを置く予定のサイトは楽天GOLDです。楽天GOLDとは、楽天の出店者向け有料オプションで、rakuten.ne.jp
ドメインにwebスペースをレンタルできる、というものです。静的なファイルしか配信できませんが、楽天は原則として楽天以外のサイトへリンクを禁止しているので、オリジナリティあふれるショップページを作るには必須のサービスです。
ともあれ、同じ楽天なら問題なかろうと思いきや、ショッピングする方の楽天はrakuten.co.jp
なんです。どうしてこうなった。
CORSが設定されているよ
今回のように同一サービス内でも通信できないのは不便なので、SOPの制限を部分的に回避できるオリジン間リソース共有(CORS)という仕組みがあります。これは通信先のサーバが、どのオリジンからの通信なら特別に許可するか、というリストをブラウザに通知することで実現します。
なんとも幸いなことに、ajaxなAPIのサーバは楽天GOLDをCORSのリストに含めています。fetchのオプションにmode: 'cors'
を指定すれば、ブラウザはそのリストを取得してからブロックするかどうか判断します。
これならajaxなAPIを回数分fetchすれば、そのまま複数商品をカートに入れることができそうです。しかし、楽天にログインしていない状態でやってみるとレスポンスは成功したようなメッセージですが、実際にカートページを見てみると何もカートに入っていません。
甘美なるCookie
皆さんクッキーは好きですか。私もたまにSet-Cookieしたりトラッカーをブロックするくらいには好きです。
というわけで、Webでクッキー(cookie)といえば、サーバがブラウザに一時的に情報を保存してもらう技術のことです。あくまでサーバがブラウザに情報の保存をお願いしているだけなので、ブラウザが保存を拒否することもできます。
一般的にcookieには全ての情報を保存するのではなく、ログインした時などにサーバが発行したIDを保存します。ブラウザは次回以降のリクエストにそのIDを含めて送信し、サーバはそのIDとログイン情報を紐付けて管理することでユーザの識別します。
fetchによるリクエストでは、オプションにcredentials: 'include'
を指定すればcookieを送信してくれますが、これを指定していてもログインしていなければカートに商品が追加されません。ログインしていれば追加されます。
詳しくは調べていないので推測も含みますが、ログインせずにAPIを呼び出すとカートに追加する処理と同時に非ログイン用のIDが発行されます。この時、カートに追加する処理自体は成功しているのでレスポンスの内容は成功したようなメッセージになります。しかし、このIDの保存に問題があると次回以降の通信でカートの識別ができなくなり、結果としてカートページでは別の新しい空のカートが表示される、ということになります。
SameSite=Laxの罠
サーバがブラウザにcookieを保存してもらう時に、逆にブラウザからcookieを送信してもらう条件や有効期限などの属性値を設定することができます。その中のSameSite
という属性は、ドメインが異なるページからのリクエストにcookieを含めるかどうか、という条件を設定する属性です。例えば、img要素などで違うドメインのURLを指定することは良くありますが、その時にcookieを送信するかどうか、といったことが制御されます。
SameSite
属性は比較的新しい属性で、指定されなかった場合は従来通りの動作――無条件にcookieをリクエストに含めるNone
が指定されたものと見なしていました。しかし、2020年夏頃からChromeの動作が変更されて、この属性が指定されなかった場合は限定的な制限をするLax
が指定されたものと見なすようになりました。Chrome以外のブラウザもこの変更に追随しています。
そして、この属性を含むいくつかのセキュリティ的な属性は、保存するときにも条件に一致するか判定されます。条件に一致しなければブラウザはcookieの保存をブロックします。
つまり前述のカートに入らない問題は、APIのレスポンスに含まれるcookieにSameSite
属性が指定されていないため、Lax
と認識されて非ログイン用のIDが保存されなかったことが原因です。
Safariという伏兵
実際問題として楽天でログインせずに購入する人は稀です。では、素直にログインをお願いすれば解決する問題でしょうか。
いくつかの環境で検証したところ、iPhoneではログインしていてもカートに追加されないことが多く観察されました。また、同じiPhoneの端末でも楽天市場アプリ経由だと追加されて、ブラウザでは追加されない、というような現象も起こりました。
この現象は、端末の設定等を色々検証した結果、Safariのトラッキング防止機能によるものと判明しました。楽天市場アプリは内部的に端末のデフォルトブラウザを使用していて、デフォルトブラウザをChromeなどに変更していたらSafariの設定の影響を受けないのでカートに追加される、ということが真相のようです。
ともあれ、さすがにトラッキング防止機能をオフにしてください、とは言えないので、別の方法を検討する必要があります。
もう1つのAPI
可能ならajaxなAPIを使いたいところでしたが、もう片方のformでPOSTしているAPIも検証してみます。
こちらのAPIのURLは、CORSが設定されていないのでfetchなどのajax的な手法は使えません。そしてcookieはSameSite
属性が設定されていないのでLax
として扱われます。これは実質的にajaxなAPIよりも制限されている状況です。
しかし、試しに楽天の商品ページを真似てform要素にhiddenタイプのinput要素などを入れてPOSTしてみると、ページ遷移はするもののカートに商品が入りました。
驚くべきことに非ログインでもカートに入る場合がありますが、実はこの非ログインで商品を追加したカートは2分後には消えてしまいます。
Lax + POST
厳密には非ログイン時に発行されたIDは最大でも2分間しかcookieに保存されません。他の商品をカートに追加しても延長などはされずに、最初にIDが発行されてから2分後にcookieが削除されます。
これはLax
のデフォルト化によって動作しなくなるシステム(ログイン連携など)に対するChromeの移行措置です。
本来のSameSite
属性の仕様では、異なるドメインに対するPOSTでLax
が指定された場合はcookieの保存や送信をしません。移行措置として、ページ移動を伴う場合(top level navigation)は2分間だけcookieが有効になる、という動作です。
いつまでこの措置が続くかは明言されていませんが、いずれ停止する措置なのでこの動作に依存するわけにはいきません。また、2分間というのも絶妙に短いです。
ちょっとどうかとも思うのですが、ものは試しにform要素のmethod属性をPOSTではなくGETにしてみたら、これでもカートに入ってしまいました。しかも2分経っても消えません。
実はSameSite
属性の仕様では、Lax
でドメインが違っていてもcookieを送信する条件が決まっています。
といってもほとんどの場合はブロックされますが、ページ移動をともなうGETの時だけ送信されます。具体的には、a要素のリンクによるページ移動、method属性にGET
が指定されているform要素のsubmitによるページ移動、location.href
への代入によるページ移動、ユーザがアドレス欄に入力したことによるページ移動などです。
ちなみにこのページ移動をともなう遷移を、iframe要素やimg要素のようなページ内側の要素に対してウィンドウ(タブ)のページそのもの(top level)が遷移(navigation)することから Top level navigation と言います。
別ウィンドウで順次ページを開く
というわけで、次のようなコードでウェイトを挟みつつ別ウィンドウで順次ページを開けば複数商品をカートに入れることができました。楽天市場アプリでは動きませんし、かなり強引なので真似をするのはおすすめしません。
async function addCart(urls) {
const w = window.open();
for (const url of urls) {
await new Promise(resolve => setTimeout(resolve, 500)); // await sleep(500);
w.location.href = url;
}
w.close();
}
Window.open()
は新規ウィンドウ(タブ)を開くメソッドですが、セキュリティ的な理由で直前に何らかのユーザ操作がないと動作をブロックされることがあります。その結果として、事実上、複数のウィンドウを開くことができません。なので1つのウィンドウを使いまわします。
このメソッドの戻り値でウィンドウをハンドルできますが、オリジンが違うページを開くとSOPの制限を受けます。この状態ではスクリプトからドキュメントの内容を参照や操作することができません。location
も参照できませんが代入してページ移動させることはできたので、適当なウェイトを挟んで順次URLを開かせることでカートに複数商品を入れることができました。ちなみにウィンドウを開いた後のウェイトがないと、1ページ目のリクエストが間に合わない端末がありました。
ちなみに、楽天市場アプリにはタブを複数開くという概念がないので、Window.open()
を実行すると現在のページを上書きしてしまいます。
ウィンドウの状態を取得できない
カートに複数商品入ったのは良いのですが、この方法にはそれなりに問題があります。
例えば売切れの商品や在庫数以上の数量をカートに入れようとすると、通常は追加できない旨のメッセージが表示されてユーザへの認識を促しますが、この方法だとメッセージが僅かな時間しか表示されずにスキップされます。また、ウェイトも固定なので回線やサーバの状況によっては、リクエストが処理される前に次のURLで上書きすることもありえます。
それに、カートに追加する過程がちらちら表示されてしまいますし、この間にユーザが何か操作した時にどういう動作するかもわかりません。ウィンドウの状態を一切取得できないので、これらの事態が発生したことをスクリプトから検出することはできません。
これらの可能性を許容した上で売切れの可能性がない商品だけにするとかすれば、まぁ使えなくはないでしょう。おすすめはしませんが。
項目選択肢の文字化け
ところで、楽天の商品ページはEUC-JPなので、formからのPOSTもEUC-JPの文字コードがパーセントエンコードされてPOSTするようになっています。もちろんサーバもEUC-JPでPOSTされる前提の作りになっています。項目選択肢choice
には基本的に日本語の文字列を入れますが、もしこれを違う文字コードでPOSTすると文字化けしてしまいます。
form要素でsubmitするなら、ページ自体をEUC-JPにしたりformのaccept-charset
属性にEUC-JP
を指定すれば、特に面倒なことをしなくてもブラウザがEUC-JPで送信してくれます。
今回のように文字列として扱いたい場合は、何らかのライブラリなどを使用して変換することになると思います。
絶妙なタイミングの仕様変更
そして、実際に1ヶ月ちょっとの間、EUC-JPに変換して外部カートを運用していましたが、ある日から文字化けが発生するようになりました。
最初はサーバ側で何か不具合でも起きたかと思っていましたが、色々調べたところサーバ側でGETの時に使用する文字コードがUTF-8に変更されていました。プライベートAPIなのでこういうことはあると思いますが、なんとも絶妙なタイミングです。
ちなみにPOSTはEUC-JPのままですが、今後、楽天全体がUTF-8化する流れなのかなと思います。
この変更によっていわゆるweb標準になったので、ライブラリを使用する必要もなくなり、パラメータの組み立てにURLSearchParams
が使えるようになりました。
まとめ
というわけで、まさにこの記事を書いている最中に仕様変更(2021年2月2日~9日)があったものの、別ウィンドウ方式で複数商品をカートに入れることができました。
ポイントは、
- URLやパラメータは商品ページのform要素を参考にする(JavaScriptオフだと、これでカートに追加される)
- 商品ページとは違ってmethodを
GET
にする - ajaxは不可、必ずページ遷移させる
- 文字コードは
UTF-8
を使用する
です。
記事中にも書いた通り、別ウィンドウで複数商品をカートに追加する方法はおすすめできません。特に売り切れ時の対策ができない場合は、普通にページ遷移で1商品だけカートに追加するほうが良いと考えられます。
追記(Refererについて)
今回は楽天GOLDで試していたので気付きませんでしたが、formからPOSTしているURLはHTTPヘッダのRefererを判定しているらしく、全く別のドメインからそのままページ遷移するとエラーになります。noreferrer
などでRefererを消せばカートに追加できます。