【第464回】 Marketing Cloud Next : スプレッドシート連携によるデータ抽出
前回の記事で書いた Marketing Cloud Engagement のデータエクステンションのデータを Google スプレッドシートに自動連携する記事 が好評頂けたので、今度は Marketing Cloud Next(つまり Data Cloud)のデータをスプレッドシートに連携する方法 を解説したいと思います。
この技術は Data Cloud Query API の技術を利用します。Data Cloud 標準の Query Editor で検索ができていれば、そのクエリを利用して出力することが可能であるというところを押さえておきましょう。
Marketing Cloud Next の場合は、以下のような手順になります。
JWT 用の鍵ペア作成
External Client App の作成
ユーザーへの事前承認(Pre-Authorization)
Apps Script 環境構築
設定手順
1. JWT 用の鍵ペア作成
1. Git for Windows をインストールします。Git for Windows(Git Bash)には OpenSSL が同梱されており、今回の鍵を作るのに最適です。

2. Git Bash を開いたら、以下の 2 行をまとめて入力して鍵を保管するフォルダを作成し、フォルダへ移動します。
mkdir -p ~/projects/jwt
cd ~/projects/jwt
3. 次に、移動したフォルダで「秘密鍵」を作ります。
openssl genpkey -algorithm RSA -out server_pkcs8.key -pkeyopt rsa_keygen_bits:2048
4. 成功すると「server_pkcs8.key」ができます。この秘密鍵は、Apps Script のコードに埋め込みます。

5. 次に、「公開証明書」を作ります。
openssl req -new -x509 -key server_pkcs8.key -out server.crt -days 3650 -sha256
6. Country Name などを入力していく必要がありますが、これらを適当に入力しても動きます。メールアドレスまで入力してください。

7. 成功すると「server.crt」ができます。この公開証明書は、次の「External Client App」の作成で使用します。

2. External Client App の作成
1. 続いて、セットアップ > External Client App Manager を検索して、新規の「External Client App」を作成します。

2. 名前や API 名を決めます。今回の私の例では、「Data Cloud Query JWT Integration」としてあります。

3. 続いて、API(OAuth 設定の有効化)を開いて OAuth を有効化します。
コールバック URL は入力が必須ですが、実際は使用されませんので、適当な「http://localhost:3000/oauth/callback」を入力します。
次の OAuth 範囲 では、以下の 2 つを選択してください。
Manage user data via APIs (api)
Perform requests at any time (refresh_token, offline_access)

4. 続いて、JWT Bearer Flow を有効化して、公開証明書(server.crt)を選択します。セキュリティはすべてオフで問題ありません。
すべて対応できたら、作成ボタンをクリックします。

3. ユーザーへの事前承認(Pre-Authorization)
1. 続いて、「ポリシー」タブで、編集ボタンをクリックします。

2. Permitted Users で「Admin approved users are pre-authorized」を選択します。

3. 続いて、App Policies に戻り、実行ユーザーのプロファイルを選択するか、実行ユーザーに与えられた権限セットを選択します。
その後、設定を「保存」します。

4. 続いて、Settings タブに移動します。

5. 中ほどに「Consumer Key and Secret」ボタンをクリックします。

6. ログインが実行されます。

7. 「Consumer Key(クライアント ID)」のみをコピーします。これは次の Apps Script のコード内で使用します。

4. Apps Script 環境構築
1. それでは、いよいよ Google スプレッドシートに移動して作業します。
スプレッドシートを開いて、メニューバーから「Extensions(拡張機能)」タブを開いて「Apps Script」 をクリックします。

2. コード入力の画面になりますので、もともと記述されているものを一旦削除して、以下のコードをコピペしてください。

const SF_LOGIN_URL = "https://login.salesforce.com"; // Sandbox: https://test.salesforce.com
const CLIENT_ID = "3MVG9GIRg253.lotxyT7qsSZdhj8gAEDPVk6.9Z4NsPAuxUAfTfanzFuk3MV4iTm22RhAYt_rxlIC4hyLKIL";
const USERNAME = "admin@rbacompany.com";
const API_VERSION = "66.0";
const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtTcj7mjX2Kguo
ahVavRHq+cr+35h3b7jBarCzdlaoJZoayVI6I/NNZzMSMcR+DGhfVG0xXOHubQ2q
kO6K+FSlMj/wyi2alpq7hS6CCoYwimfuPI4+CnNrVIQiksMe026kXmZ8tEpW6yV5
lGmC/85p99bE8Pm0CgnlNcsfflx7gYtum9nAVzyu4cqJjYEdSudXh4eSnse0C0Tw
dWs27aMOz9+7/7QX6QxdgSwkoorkR7KplSE8qrxAoVxPQomzP2ITetaHExbRh+LH
qC8/wzLo/1uQuFi6v9sp3O6DIcv+K7fNg56o3ATCM27Xt7sFPS+sqIU7LW7mhMja
CCPs64irAgMBAAECggEAERqQL2S01qqno+N0YBQw5IPqqOTgY0k/brdc4RlYzBeJ
8gLUfrB1nroErFMFFXucAWyPqkOEeMeChcbwA/8mO3eOH/GUNqGOe9tVD7iCLeA7
CaQoVa8qXPlmYRMi9rPfQ5Gdg8k3XQSwGiOvliIw+Pxg0ecGfeJPv7NjbKRH9FhX
FZ0h8v67VPmKIAmRvEfOBV2G8Qp6vIWwS+bCGHspUtqzeIdPDbWbqjSsiddEGqeD
/pDpvcseMIclaKzY0za0DeR9x2iDTnJPIQ/BxE55RAO/vnkjyo/1Wn0UcGe5E5KE
3lOFe0gpFObtmHqPaQnO25xJ6EjlmJHFZ9mRmjhYoQKBgQDZccMNagVRJW7WOz/H
A2D3LFVXQ1247ArD4CA9P3BFNJF2GCLMs3vOOGXKCbnVssW892s9zv6DAoFsbuka
om4vwocBPXuTrqdnIy0K8IuwQnOzGMyHskO629m46WOG0FfdvG4PMOvwZ8HvkQQJ
QG7e4eAJNlPbkAvSfD+Hz0TTXQKBgQDMCGU+Ui1BrGcduQWfRbT4Cc8Rz5f7qhAl
LS8arp0d+HtF5AeqjtRpX4NKGX3sr9xBIZVfwla7jWOe0x6jd20Co5KjlGKvWR2w
i6tRHGMk0/FOxcdQVR7hWw5+DSaLBmwi3AhE8ro8rqsGqVPqnutzP9BXG7Hr8BDe
jfPucxDTpwKBgQCX5kbSGhw4waOZ+K3nAs88HDZJzX+tbQdgKjObVbPCRKTREK9O
vJtiRjelWgH97PMBvP2nofBd6OQssZYZyxqaNpRFI4QueLXs8L/Igp2ytdlJZauL
p9Z0tJx19mRWizi2Z6mi5xQLTxBFoNJm/CH3hWcSSGdwXEJF+hIPd5Wm6QKBgBMe
UkZVsvHtcrghRzqWcI+xc5rKpgYp+FtTcY+Bfy14xCxXYrSDr7mz/nxqCRetnujn
ebTAZBos9IHEbKGKpkdSBoKXe+vMYPDTFZmDHHMt/PWRqMyJPVyGiMQc/ViXoHhf
v9KeH/9hqpr0MO3SOGPTPfV7nd9q3lnMWWgllhUPAoGAesQ2ItKlppmOG7mnLmz8
9x5nxc3jJIktMusIQ0WxGnXdZDMTBfIsdZ1eCd7UbX4qfUXq8+JaYMEZ2z7GdJ8n
y6UZ9hdEw61n7UxekFLUNLST7d/JnShaOdPmmX9oDoQuFEJvgGGRflQqBFoX5bsK
xY53m5DJwquL0nJUzzQZ9M=
-----END PRIVATE KEY-----`;
const SHEET_NAME = "Data_Cloud_Query";
const LIMIT_ROWS = 100000;
function run_DataCloudQuery_JWT_toSheets() {
const token = getAccessTokenByJwt_();
const accessToken = token.access_token;
const instanceUrl = token.instance_url;
const sql = `
SELECT
a.ssot__IndividualId__c AS Id,
a.ssot__SendtimeEmailAddress__c AS Email,
i.ssot__FirstName__c AS FirstName,
i.ssot__LastName__c AS LastName,
a.ssot__EngagementDateTm__c + interval '9 hour' AS EngagementDateTime,
a.ssot__EngagementChannelActionId__c AS ActionName,
a.ssot__EmailRecipientSendStatus__c AS SendStatus,
c.ssot__Name__c AS EmailElementAPIName,
e.ssot__Name__c AS FlowName,
d.ssot__VersionNumber__c AS VersionNumber,
h.ssot__Name__c AS SegmentName,
a.ssot__EngagementActionReasonText__c AS ActionReason,
a.ssot__EmailBounceType__c AS BounceType,
a.ssot__BounceReasonText__c AS BounceReason,
a.ssot__UnsubscribeSourceText__c AS UnsubscribeSource,
a.ssot__ResolvedURL__c AS LinkURL,
g.ssot__Subject__c AS EmailSubject,
g.ssot__FromAddress__c AS FromAddress,
g.ssot__MessagePurpose__c AS MessagePurpose
FROM ssot__EmailEngagement__dlm a
JOIN ssot__FlowElementRun__dlm b ON a.ssot__FlowElementRunId__c = b.ssot__Id__c
JOIN ssot__FlowElement__dlm c ON b.ssot__FlowElementId__c = c.ssot__Id__c
JOIN ssot__FlowVersion__dlm d ON c.ssot__FlowVersionId__c = d.ssot__Id__c
JOIN ssot__Flow__dlm e ON d.ssot__FlowId__c = e.ssot__Id__c
JOIN ssot__BulkEmailMessage__dlm g ON a.ssot__BulkEmailMessageId__c = g.ssot__Id__c
LEFT OUTER JOIN ssot__MarketSegment__dlm h ON g.ssot__MarketSegmentId__c = h.ssot__Id__c
JOIN ssot__Individual__dlm i ON a.ssot__IndividualId__c = i.ssot__Id__c
ORDER BY a.ssot__EngagementDateTm__c DESC
LIMIT ${LIMIT_ROWS}
`.trim();
const body = postSqlSync_(instanceUrl, accessToken, sql);
const headers = extractSelectHeaders_(sql);
writeDataCloudResultToSheet_(SHEET_NAME, body, headers);
}
function getAccessTokenByJwt_() {
const now = Math.floor(Date.now() / 1000);
const jwtPayload = {
iss: CLIENT_ID,
sub: USERNAME,
aud: SF_LOGIN_URL,
exp: now + 180
};
const assertion = buildJwtRs256_(jwtPayload, PRIVATE_KEY_PEM);
const url = `${SF_LOGIN_URL}/services/oauth2/token`;
const res = UrlFetchApp.fetch(url, {
method: "post",
payload: {
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion
},
muteHttpExceptions: true
});
const code = res.getResponseCode();
const text = res.getContentText();
Logger.log("=== JWT token ===");
Logger.log("HTTP: " + code);
Logger.log("Body(first 2000): " + (text ? text.slice(0, 2000) : ""));
if (code >= 300) throw new Error(`JWT token failed: HTTP ${code} / ${text}`);
const obj = JSON.parse(text);
if (!obj.access_token || !obj.instance_url) {
throw new Error("Token response missing access_token/instance_url: " + text);
}
return obj;
}
function buildJwtRs256_(payloadObj, privateKeyPem) {
const header = { alg: "RS256", typ: "JWT" };
const encHeader = base64UrlEncode_(JSON.stringify(header));
const encPayload = base64UrlEncode_(JSON.stringify(payloadObj));
const signingInput = `${encHeader}.${encPayload}`;
const signatureBytes = Utilities.computeRsaSha256Signature(signingInput, privateKeyPem);
const encSig = base64UrlEncodeBytes_(signatureBytes);
return `${signingInput}.${encSig}`;
}
function base64UrlEncode_(str) {
return base64UrlEncodeBytes_(Utilities.newBlob(str).getBytes());
}
function base64UrlEncodeBytes_(bytes) {
const b64 = Utilities.base64Encode(bytes);
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function postSqlSync_(instanceUrl, accessToken, sql) {
const url = `${instanceUrl}/services/data/v${API_VERSION}/ssot/query-sql`;
const res = UrlFetchApp.fetch(url, {
method: "post",
contentType: "application/json",
headers: { Authorization: `Bearer ${accessToken}` },
payload: JSON.stringify({ sql }),
muteHttpExceptions: true
});
const code = res.getResponseCode();
const text = res.getContentText();
Logger.log("=== Data Cloud query ===");
Logger.log("HTTP: " + code);
Logger.log("Body(first 2000): " + (text ? text.slice(0, 2000) : ""));
if (code >= 300) throw new Error(`Query failed: HTTP ${code} / ${text}`);
const obj = JSON.parse(text);
if (!obj.data) throw new Error("No data in response (unexpected format): " + text.slice(0, 2000));
return obj;
}
function extractSelectHeaders_(sql) {
const cleaned = stripSqlComments_(sql).trim();
const m = cleaned.match(/\bselect\b([\s\S]*?)\bfrom\b/i);
if (!m) return null;
const selectPart = m[1].trim();
if (selectPart === "*" || /\*/.test(selectPart)) return null;
const items = splitByCommaOutsideParens_(selectPart);
const headers = items.map(expr => headerFromSelectItem_(expr)).filter(Boolean);
return headers.length ? headers : null;
}
function stripSqlComments_(sql) {
return sql.replace(/\/\*[\s\S]*?\*\//g, "").replace(/--.*$/gm, "");
}
function splitByCommaOutsideParens_(s) {
const out = [];
let buf = "";
let depth = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === "(") depth++;
if (ch === ")") depth = Math.max(0, depth - 1);
if (ch === "," && depth === 0) {
out.push(buf.trim());
buf = "";
} else {
buf += ch;
}
}
if (buf.trim()) out.push(buf.trim());
return out;
}
function headerFromSelectItem_(item) {
const t = item.trim().replace(/\s+/g, " ");
const asMatch = t.match(/\s+as\s+([A-Za-z0-9_]+)$/i);
if (asMatch) return asMatch[1];
const parts = t.split(" ");
if (parts.length >= 2) {
const last = parts[parts.length - 1];
const beforeLast = parts.slice(0, -1).join(" ");
if (!/[().]/.test(last) && /[().]/.test(beforeLast) === false) {
return last;
}
}
const col = t.replace(/^.*\./, "");
return col;
}
function writeDataCloudResultToSheet_(sheetName, body, explicitHeaders) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(sheetName) || ss.insertSheet(sheetName);
sheet.clearContents();
const data = body.data;
if (!Array.isArray(data) || data.length === 0) {
sheet.getRange(1, 1).setValue("No rows.");
return;
}
if (Array.isArray(data[0])) {
const colCount = data[0].length;
const headers =
Array.isArray(explicitHeaders) && explicitHeaders.length === colCount
? explicitHeaders
: Array.from({ length: colCount }, (_, i) => `col_${i + 1}`);
const CHUNK = 5000;
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
for (let i = 0; i < data.length; i += CHUNK) {
const chunk = data.slice(i, i + CHUNK);
sheet.getRange(2 + i, 1, chunk.length, headers.length).setValues(chunk);
}
return;
}
sheet.getRange(1, 1).setValue("Unexpected data format.");
sheet.getRange(2, 1).setValue(JSON.stringify(body).slice(0, 5000));
}3. ここで、皆さんの情報に変更する必要があるのは、以下の情報なので書き換えてください。
const SF_LOGIN_URL = "https://login.salesforce.com"; // Sandbox: https://test.salesforce.com
const CLIENT_ID = "3MVG9GIRg253.lotxyT7qsSZdhj8gAEDPVk6.9Z4NsPAuxUAfTfanfzFuk3MV4iTm22RhAYt_rxlIC4hyLKIL";
const USERNAME = "admin@nac-care.com";
const API_VERSION = "66.0";
const PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCtTcj7mjX2Kguo
ahVavRHq+cr+35h3b7jBarCzdlaoJZoayVI6I/NNZzMSMcR+DGhfVG0xXOHubQ2q
kO6K+FSlMj/wyi2alpq7hS6CCoYwimfuPI4+CnNrVIQiksMe026kXmZ8tEpW6yV5
lGmC/85p99bE8Pm0CgnlNcsfflx7gYtum9nAVzyu4cqJjYEdSudXh4eSnse0C0Tw
dWs27aMOz9+7/7QX6QxdgSwkoorkR7KplSE8qrxAoVxPQomzP2ITetaHExbRh+LH
qC8/wzLo/1uQuFi6v9sp3O6DIcv+K7fNg56o3ATCM27Xt7sFPS+sqIU7LW7mhMja
CCPs64irAgMBAAECggEAERqQL2S01qqno+N0YBQw5IPqqOTgY0k/brdc4RlYzBeJ
8gLUfrB1nroErFMFFXucAWyPqkOEeMeChcbwA/8mO3eOH/GUNqGOe9tVD7iCLeA7
CaQoVa8qXPlmYRMi9rPfQ5Gdg8k3XQSwGiOvliIw+Pxg0ecGfeJPv7NjbKRH9FhX
FZ0h8v67VPmKIAmRvEfOBV2G8Qp6vIWwS+bCGHspUtqzeIdPDbWbqjSsiddEGqeD
/pDpvcseMIclaKzY0za0DeR9x2iDTnJPIQ/BxE55RAO/vnkjyo/1Wn0UcGe5E5KE
3lOFe0gpFObtmHqPaQnO25xJ6EjlmJHFZ9mRmjhYoQKBgQDZccMNagVRJW7WOz/H
A2D3LFVXQ1247ArD4CA9P3BFNJF2GCLMs3vOOGXKCbnVssW892s9zv6DAoFsbuka
om4vwocBPXuTrqdnIy0K8IuwQnOzGMyHskO629m46WOG0FfdvG4PMOvwZ8HvkQQJ
QG7e4eAJNlPbkAvSfD+Hz0TTXQKBgQDMCGU+Ui1BrGcduQWfRbT4Cc8Rz5f7qhAl
LS8arp0d+HtF5AeqjtRpX4NKGX3sr9xBIZVfwla7jWOe0x6jd20Co5KjlGKvWR2w
i6tRHGMk0/FOxcdQVR7hWw5+DSaLBmwi3AhE8ro8rqsGqVPqnutzP9BXG7Hr8BDe
jfPucxDTpwKBgQCX5kbSGhw4waOZ+K3nAs88HDZJzX+tbQdgKjObVbPCRKTREK9O
vJtiRjelWgH97PMBvP2nofBd6OQssZYZyxqaNpRFI4QueLXs8L/Igp2ytdlJZauL
p9Z0tJx19mRWizi2Z6mi5xQLTxBFoNJm/CH3hWcSSGdwXEJF+hIPd5Wm6QKBgBMe
UkZVsvHtcrghRzqWcI+xc5rKpgYp+FtTcY+Bfy14xCxXYrSDr7mz/nxqCRetnujn
ebTAZBos9IHEbKGKpkdSBoKXe+vMYPDTFZmDHHMt/PWRqMyJPVyGiMQc/ViXoHhf
v9KeH/9hqpr0MO3SOGPTPfV7nd9q3lnMWWgllhUPAoGAesQ2ItKlppmOG7mnLmz8
9x5nxc3jJIktMusIQ0WxGnXdZDMTBfIsdZ1eCd7UbX4qfUXq8+JaYMEZ2z7GdJ8n
y6UZ9hdEw61n7UxekFLUNLST7d/JnShaOdPmmX9oDoQuFEJvgGGRflQqBFoX5bsK
xY53m5DJwquL0nJUzzQZ9M=
-----END PRIVATE KEY-----`;クライアント ID(コンシューマーキー)
実行ユーザー名(権限が付与されているあなたのユーザー名)
秘密鍵
4. そして、もっも大事なクエリの部分ですが、前述の通り Query Editor で実行できるものは、そのまま利用が可能です。
以下の例は、Email Engagement に関連するデータを取得している例です。これがサンプルコードの中にあることが確認できると思います。
SELECT
a.ssot__IndividualId__c AS Id,
a.ssot__SendtimeEmailAddress__c AS Email,
i.ssot__FirstName__c AS FirstName,
i.ssot__LastName__c AS LastName,
a.ssot__EngagementDateTm__c + interval '9 hour' AS EngagementDateTime,
a.ssot__EngagementChannelActionId__c AS ActionName,
a.ssot__EmailRecipientSendStatus__c AS SendStatus,
c.ssot__Name__c AS EmailElementAPIName,
e.ssot__Name__c AS FlowName,
d.ssot__VersionNumber__c AS VersionNumber,
h.ssot__Name__c AS SegmentName,
a.ssot__EngagementActionReasonText__c AS ActionReason,
a.ssot__EmailBounceType__c AS BounceType,
a.ssot__BounceReasonText__c AS BounceReason,
a.ssot__UnsubscribeSourceText__c AS UnsubscribeSource,
a.ssot__ResolvedURL__c AS LinkURL,
g.ssot__Subject__c AS EmailSubject,
g.ssot__FromAddress__c AS FromAddress,
g.ssot__MessagePurpose__c AS MessagePurpose
FROM ssot__EmailEngagement__dlm a
JOIN ssot__FlowElementRun__dlm b ON a.ssot__FlowElementRunId__c = b.ssot__Id__c
JOIN ssot__FlowElement__dlm c ON b.ssot__FlowElementId__c = c.ssot__Id__c
JOIN ssot__FlowVersion__dlm d ON c.ssot__FlowVersionId__c = d.ssot__Id__c
JOIN ssot__Flow__dlm e ON d.ssot__FlowId__c = e.ssot__Id__c
JOIN ssot__BulkEmailMessage__dlm g ON a.ssot__BulkEmailMessageId__c = g.ssot__Id__c
LEFT OUTER JOIN ssot__MarketSegment__dlm h ON g.ssot__MarketSegmentId__c = h.ssot__Id__c
JOIN ssot__Individual__dlm i ON a.ssot__IndividualId__c = i.ssot__Id__c
ORDER BY a.ssot__EngagementDateTm__c DESCTips:例のように AS で別名を付けることで、スプレッドシートでカラム名に一貫性を持たせることが可能ですので、必ず AS を利用してください。
5. これで、関数を実行してください。

6. アクセス権限を許諾してください。

7. すると、読み込みが開始しますので、完了を待ちます。

8. スプレッドシートを確認すると、データが確認できました。成功です。

9. この結果は、Query Editor で同じクエリで検索した結果と同じです。

いかがでしたでしょうか。
今回の作業を一度行っておくだけで、Data Cloud 内の欲しいデータが欲しい時にスプレッドシート上で閲覧できるようになります。
但し、私のように「デモ環境」のようなものであれば良いのですが、実際の本番環境で行う場合は、個人情報の流出の懸念など、どうしても気軽にはできないものではありますので、十二分に注意してください。
今回のようなクエリのサンプルは、以下の記事で少しだけ公開しています。これらも参考にしてください。
今回は以上です。
