メインコンテンツへスキップ
見出し画像
Photo byvoice_miwa0401

【第602回】Personalization:Affinity に応じたレコメンドを Web に表示する

    Nobuyuki Watanabe

    前回の記事では、指定されたカテゴリの商品を価格の昇順で返す Recommender を作成 しました。

    今回は、顧客の Affinity が最も高いカテゴリを取得し、そのカテゴリを Recommender へ渡して、TrailNest のトップページに商品カードを表示します。

    例えば、Apparel への興味が最も高い顧客には Apparel の商品を表示します。その後の行動で Sleeping が最高スコアになれば、更新された Affinity を参照する次の取得で、Sleeping の商品へ切り替える構成です。

    今回は、既存の Hero Banner は Web Personalization Manager で表示し、レコメンド部分は Decisioning API と JavaScript を使って表示します。

    https:// YOUR Tenant Endpoint /personalization/decisions


    Personalization 基礎シリーズ

    Sitemap Builder シリーズ

    Data 360 設定シリーズ

    Personalization 設定シリーズ


    今回つなぐ処理

    処理は次の順番で行います。

    1. 既存の Hero 用 Personalization Point から、最高スコアのカテゴリを取得する

    2. 返された attributes.Top_Category を読み取る

    3. レコメンド用のリクエストに context.Top_Category を設定する

    4. 返された商品を TrailNest の #home_recs に表示する

    Hero のレスポンスにある Top_Category が、別のレコメンド用リクエストに自動で引き継がれるわけではありません。 今回は、この値を渡す処理を JavaScript で追加します。

    前回の Recommender に設定した {!Top_Category} は、このリクエストで渡す値を使います。カテゴリ内の順番は、前回と同じく価格の昇順です。


    ① Content Schema を作成する

    Personalization アプリで Content Schemas を開き、新規作成します。

    • Personalization Type:Recommendations

    • Name:TrailNest Category Recommendation Content

    • API Name:TrailNest_Category_Recommendation_Content

    画像

    次のページの Attribute 設定ですが、ここは不要で次へ進みます。

    画像

    この画面では、おすすめ商品と一緒に返す「見出し」などの項目を追加できます。例えば「見出し」という項目を追加すると、後で Decision を作成する際に「あなたへのおすすめ」などの文字を設定できます。

    但し、今回は、その文字を Web ページ側に直接用意するため、ここでは何も追加せず、Next をクリックします。

    次に、商品として返す DMO には、今回の Item Data Graph のルートである Goods Product(ssot__GoodsProduct__dlm)を選択します。

    返却項目には、次の情報に対応する項目を追加します。

    • 商品 ID

    • 商品名

    • 商品カテゴリ

    • 価格

    • 画像 URL

    • 商品ページ URL

    画像

    各項目は、Content Schema だけでなく、Item Data Graph 側にも含めておきます。カテゴリは、返却商品が指定したカテゴリに一致することを確認するためにも使用します。


    ② Personalization Point を作成する

    Personalization Points を開き、New → Manual Setup から作成します。

    • Profile Data Graph:Real-Time > TrailNest Profile

    • Name:TrailNest Home Category Recommendations

    • API Name:TrailNest_Home_Category_Recommendations

    • Personalization Type:Recommendations

    • Content Schema:TrailNest Category Recommendation Content

    • Maximum Number of Recommendations to Return:4

    • Authentication Required:未チェック

    画像

    保存して、Personalization Decision の追加へ進みます。


    ③ Personalization Decision を作成する

    次の内容で Decision を作成します。

    • Decision Name:TrailNest Category Product Picks

    • Decision API Name:TrailNest_Category_Product_Picks

    • Decision State:Live

    画像
    • Recommender:TrailNest Category Recommendations

    画像
    • Targeting Rules:Always

    画像

    そして、前回の記事で Recommender を作成していない場合は、以下で作成してください。

    • Category の Filter Type:Static

    • Operator:Is Equal To

    • Value:{!Top_Category}

    • 商品の並び順:Price / Ascending

    • Fallback Recommender:無効

    画像

    ④ API で動的な絞り込みを確認する

    Web 表示に進む前に、カテゴリをリクエストに渡したときの商品選択を確認します。今回は、公開済み TrailNest のページ上から API を呼び出します。

    認証不要で呼び出す設定

    今回の Personalization Point は、Authentication Required を未チェックにしています。公開 Web サイトから利用するため、今回のリクエストには Authorization ヘッダーやアクセストークンを付ける必要がありません。

    なお、認証不要でも、どの送信元からでも同じように呼び出せるとは限りません。今回の検証では、API Tester からは 403 が返りましたが、TrailNest のページ上からは 200 が返りました。

    Console からリクエストを送信する

    1. 公開済み TrailNest のトップページを開きます。

    2. F12 で開発者ツールを開き、Console タブを選択します。

    3. 以下のコードを実行します。

    • individualId は、検証対象のブラウザの deviceId に置き換えます。

    • URL は、自分の環境の Tenant Endpoint に /personalization/decisions を付けたものを使用してください。

    画像
    fetch("https:// YOUR Tenant Endpoint /personalization/decisions", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        context: {
          individualId: " YOUR deviceId ",
          dataspace: "default",
          Top_Category: "apparel"
        },
        personalizationPoints: [
          {
            name: "TrailNest_Home_Category_Recommendations"
          }
        ]
      })
    })
    .then(async response => {
      const body = await response.text();
      console.log("Status:", response.status);
      try {
        console.log(JSON.parse(body));
      } catch {
        console.log(body);
      }
    })
    .catch(console.error);

    今回は、Recommender の Filter を固定値に戻すのではなく、動的変数のまま、リクエストで apparel を渡します。 この段階では、Affinity からカテゴリを取得する処理はまだ行いません。

    返された商品を確認する

    Console に Status: 200 が表示されたら、返されたオブジェクトの personalizations → 0 → data を展開します。

    画像
    • 商品カテゴリの Category__c が apparel になっている

    • 価格の UnitPrice__c が安い順に並んでいる

    • 商品数が最大 4 件になっている

    Top_Category を渡さない場合、動的変数を使用する Filter は破棄されます。全カテゴリの商品が返る場合は、リクエストに Top_Category が含まれているか、Recommender の {!Top_Category} と名前が一致しているかを確認してください。


    ⑤ CloudPages に商品表示の処理を追加する

    TrailNest には、Home・商品詳細・注文完了の3か所に、おすすめ商品の表示場所を用意しています。今回は、どのページでも、顧客の Affinity が最も高いカテゴリの商品を最大 4 件表示します。

    続いて、CloudPages の 閉じる </body> タグの直前に、以下を追加します。

    • コード冒頭の endpoint にある YOUR_TENANT_HOST は、④で確認した、自分の環境のテナントホストへ置き換えてください。

    • fields には、今回のコンソールのレスポンスで確認した商品項目の実際の API 名を設定しています。皆さんが別の項目を使用している場合は、実際に返された JSON のキーに合わせてください。

    <style>
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1)[hidden] {
      display:none;
    }
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1).tn-recs-ready {
      border:0;
      padding:0;
      text-align:left;
      color:#1d2a22;
    }
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid {
      display:grid;
      grid-template-columns:repeat(4,minmax(0,1fr));
      gap:22px;
    }
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .product-card {
      display:block;
      min-width:0;
    }
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .product-image {
      display:block;
      height:230px;
    }
    :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) a:focus-visible {
      outline:3px solid #234b35;
      outline-offset:4px;
    }
    @media(max-width:900px) {
      :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid {
        grid-template-columns:repeat(2,minmax(0,1fr));
      }
    }
    @media(max-width:480px) {
      :is(#home_recs, #product_detail_recs_row_1, #order_confirmation_recs_row_1) .tn-recs-grid {
        grid-template-columns:1fr;
      }
    }
    </style>
    
    <script>
    (() => {
      const config = {
        endpoint: "https://YOUR_TENANT_HOST/personalization/decisions",
        dataspace: "default",
        heroPoint: "TrailNest_Home_Hero",
        recsPoint: "TrailNest_Home_Category_Recommendations",
        limit: 4,
        currency: "JPY",
        fields: {
          id: "ssot__Id__c",
          name: "ssot__Name__c",
          category: "Category__c",
          price: "UnitPrice__c",
          image: "ImageUrl__c",
          url: "Link_URL__c"
        }
      };
    
      const labels = {
        backpacks: "Backpacks",
        tents: "Tents & Shelter",
        sleeping: "Sleeping Gear",
        apparel: "Apparel",
        footwear: "Footwear",
        accessories: "Accessories"
      };
    
      const text = value => value == null ? "" : String(value);
      const value = (item, field) => text(item[config.fields[field]]);
    
      const safeUrl = input => {
        if (!input) return null;
        try {
          const url = new URL(input, location.href);
          return ["https:", "http:"].includes(url.protocol)
            ? url.href
            : null;
        } catch {
          return null;
        }
      };
    
      async function request(point, individualId, extraContext = {}) {
        const response = await fetch(config.endpoint, {
          method: "POST",
          headers: {
            "Content-Type": "application/json"
          },
          signal: AbortSignal.timeout(10000),
          body: JSON.stringify({
            context: {
              individualId,
              dataspace: config.dataspace,
              channel: "Web",
              requestUrl: location.href,
              ...extraContext
            },
            personalizationPoints: [
              { name: point }
            ]
          })
        });
    
        if (!response.ok) {
          throw new Error(`Decision request: ${response.status}`);
        }
    
        const payload = await response.json();
        const result = payload.personalizations?.find(
          item => item.personalizationPointName === point
        );
    
        if (!result) {
          throw new Error(`No response for ${point}`);
        }
    
        return result;
      }
    
      function createCard(item, category) {
        const card = document.createElement("a");
        card.className = "product-card";
    
        const id = value(item, "id");
        let href;
    
        if (config.fields.url) {
          href = safeUrl(value(item, "url"));
        } else if (id) {
          const url = new URL(location.pathname, location.origin);
          url.searchParams.set("view", "product");
          url.searchParams.set("id", id);
          href = url.href;
        }
    
        if (!href) {
          throw new Error("Product link is missing or invalid");
        }
    
        card.href = href;
        card.dataset.productId = id;
    
        const imageUrl = safeUrl(value(item, "image"));
        if (imageUrl) {
          const image = document.createElement("img");
          image.className = "product-image";
          image.src = imageUrl;
          image.alt = "";
          image.loading = "lazy";
          card.append(image);
        }
    
        const body = document.createElement("div");
        body.className = "product-body";
    
        const badge = document.createElement("div");
        badge.className = "product-category";
        badge.textContent = labels[category];
    
        const title = document.createElement("h3");
        title.textContent = value(item, "name");
    
        if (!title.textContent) {
          throw new Error("Product name is missing");
        }
    
        const price = document.createElement("div");
        price.className = "product-price";
    
        const rawPrice = value(item, "price");
        const amount = Number(rawPrice);
    
        if (!rawPrice || !Number.isFinite(amount)) {
          throw new Error("Product price is missing or invalid");
        }
    
        price.textContent = new Intl.NumberFormat("en-US", {
          style: "currency",
          currency: config.currency
        }).format(amount);
    
        body.append(badge, title, price);
        card.append(body);
    
        return card;
      }
    
      async function run() {
        const page = document.querySelector("#trailnest-app");
        const selectors = {
          home: "#home_recs",
          product_detail: "#product_detail_recs_row_1",
          order_confirmation: "#order_confirmation_recs_row_1"
        };
    
        const selector = selectors[page?.dataset.pageType];
        const host = selector ? page.querySelector(selector) : null;
    
        if (!host) return;
    
        host.hidden = true;
    
        try {
          let individualId;
    
          for (let attempt = 0; attempt < 100; attempt++) {
            const sdk = window.SalesforceInteractions;
            individualId = sdk?.getAnonymousId?.();
    
            if (individualId) break;
    
            await new Promise(resolve => setTimeout(resolve, 100));
          }
    
          if (!individualId) {
            throw new Error("SDK Individual ID is unavailable");
          }
    
          const hero = await request(config.heroPoint, individualId);
          const category = hero.attributes?.Top_Category;
    
          if (!Object.hasOwn(labels, category)) return;
    
          const recs = await request(config.recsPoint, individualId, {
            Top_Category: category
          });
    
          const items = recs.data;
    
          if (!Array.isArray(items) || items.length === 0) return;
    
          if (items.some(item => value(item, "category") !== category)) {
            throw new Error(
              "Returned product category does not match Top_Category"
            );
          }
    
          const grid = document.createElement("div");
          grid.className = "tn-recs-grid";
    
          items.slice(0, config.limit).forEach(
            item => grid.append(createCard(item, category))
          );
    
          host.replaceChildren(grid);
          host.dataset.topCategory = category;
          host.classList.add("tn-recs-ready");
          host.hidden = false;
    
        } catch (error) {
          console.error("[TrailNest recommendations]", error);
        }
      }
    
      if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", run, {
          once: true
        });
      } else {
        run();
      }
    })();
    </script>

    このコードでは、次の順番で処理します。

    1. Web SDK から、現在のブラウザの ID を取得する

    2. Hero 用の Decision から、最高スコアのカテゴリを取得する

    3. 取得したカテゴリを context.Top_Category に入れ、レコメンド用の Point へ送る

    4. 返された商品を、現在のページの表示場所に商品カードとして表示する

    商品詳細や注文完了でも、閲覧中の商品や購入商品を基準にするのではなく、顧客の Affinity を基準にします。

    ブラウザの ID は SalesforceInteractions.getAnonymousId() で取得し、2 回のリクエストに同じ ID を使用します。顧客の ID をコードへ固定する必要はありません。

    今回は Personalization Point の Authentication Required を未チェックにしているため、リクエストに Authorization ヘッダーやアクセストークンは追加しません。④と同じく、TrailNest 上から呼び出します。

    Affinity がなく default が返った場合、対応していないカテゴリの場合、商品が返らない場合は、商品カードを表示しません。

    今回の商品カードは JavaScript で直接表示します。そのため、WPM の Experience Template による表示・クリックの自動計測は付きません。


    ⑥ 商品カードを確認する

    </body> の直前にコードを配置したら、CloudPages を公開し、Affinity を参照できているブラウザでトップページを開きます。

    画像

    次の 3 か所で商品カードの表示を確認します。

    • Home:Recommended for You の箇所

    画像
    • 商品詳細:You May Also Like の箇所

    画像
    • 注文完了:Complete Your Adventure の表示場所

    画像

    どのページでも、顧客の最高スコアのカテゴリに該当する商品が表示されることを確認します。

    • 商品カードが最大 4 件表示される

    • 商品画像、商品名、カテゴリ、価格が表示される

    • 商品が価格の安い順に並んでいる

    • 商品カードをクリックすると、該当商品の詳細ページへ移動する

    • カテゴリ・カート・チェックアウトでは、今回のレコメンド取得処理が実行されない

    同じカテゴリを数回カートに入れる(Add to Cart を連打する)などして、興味の強いカテゴリを変えて、そのカテゴリに合わせてバナーやおすすめ商品が変わることを確認してください。

    以下は、テントの商品を連打した結果です。

    今回の検証サイトは公開しているので、こちらのサイト でお試しください。

    画像

    いかがでしたでしょうか。

    今回は、Affinity が最も高いカテゴリを取得し、その値を Recommender へ渡して、TrailNest の Home・商品詳細・注文完了に商品カードを表示する構成を紹介しました。

    Affinity が顧客の興味をカテゴリごとに数値化し、Decision が最高スコアのカテゴリを返します。そのカテゴリを使って Recommender が商品を選び、Web ページ上に表示します。

    今回の商品選択は、過去に閲覧した商品だけを並べるものではありません。顧客が最も興味を持つカテゴリから、まだ閲覧していない商品も含めておすすめできます。 同じカテゴリ内では、検証用に価格の昇順で表示しています。

    前回の Hero Banner と組み合わせることで、顧客の興味に合わせて、バナーとおすすめ商品の両方を切り替えられるようになりました。

    次の Personalization の記事では、Hero とは独立した設定で Affinity に応じた商品を選び、Experience Template と WPM で表示する構成を検証します。

    今回は以上です。


    次の記事はこちら

    前回の記事はこちら

    私の note のトップページはこちら

     
     
     
    Salesforce Marketing Cloud、Agentforce、Data Cloud、Salesforce 認定資格に関する実践的な情報を発信しています。これらの記事が、皆さまの学習や日々の業務に少しでもお役に立てば幸いです。