Salesforce 接続アプリ作成の依頼手順書
Salesforceのレポートをスプレッドシートに自動取得するため、読み取り用の「外部クライアントアプリケーション」を1つ作成し、発行される鍵2つを依頼する担当者にお渡しいただきたい、という依頼です。
作業時間: 約10分(+設定反映の待ち時間 最大10分)/ 必要権限: システム管理者
セキュリティ上のポイント
- OAuth範囲は「APIを使用してユーザーデータを管理 (api)」のみ。外部サービスへの常時接続やデータ送信は発生しません(スプレッドシート側から取得しに行くだけです)
- アクセス範囲は「実行ユーザー」に指定した1ユーザーの閲覧権限と同一です。担当者を実行ユーザーにすれば、担当者が画面で見られる以上のデータには一切アクセスできません
- 鍵はコードには書かれず、担当者のGoogleアカウント内の非公開領域にのみ保存されます(シートを共有しても第三者からは見えません)
A情シス側の作業(約10分)
A-1. 外部クライアントアプリケーションの新規作成
- 設定 → クイック検索「アプリケーションマネージャ」
- 右上の「新規外部クライアントアプリケーション」をクリック
新しいUIには「新規接続アプリケーション」ボタンはありません。「新規外部クライアントアプリケーション」が後継で、設定項目は同等です。
- 基本情報を入力:
- アプリケーション名:
SFレポート取得(マーケ)など任意 - API参照名: 半角英数字とアンダースコアのみ(例:
SF_Report_Marketing) - 取引先責任者メール: 作業者のメール / 配信状態: ローカル
- アプリケーション名:
アプリ名を日本語にすると、自動入力されるAPI参照名が不正になり保存エラーになります。API参照名を手で英数字に直してください。
A-2. OAuth設定
- 「OAuthを有効化」にチェック
- コールバックURL:
https://localhost(使いませんが入力必須です) - OAuth範囲: 「APIを使用してユーザーデータを管理 (api)」だけを右側の「選択したOAuth範囲」へ移動。full や web は追加しない
コールバックURLの貼り付けが二重(
https://localhosthttps://localhost)になる事故が起きやすいので確認してください。A-3. クライアントログイン情報フローの有効化(いちばん間違えやすい所)
- 同じ画面を下へスクロール →「OAuthフローおよび外部クライアントアプリケーションの機能強化」の「クライアントログイン情報フローを有効化」にチェック
- 直下に出る「(ユーザー名) として実行」に、実行ユーザーのSalesforceユーザー名を入力
- 保存
メールアドレスではなく「ユーザー名」です(見た目は似ていますが別物のことがあります)。設定 → ユーザー → 対象者の行の「ユーザー名」列の値をコピーして貼るのが確実です。
保存後に修正する場合は、必ず既存アプリを開いて編集してください。もう一度「新規」から作ると「already exists」エラーになります。既存アプリの一覧は「アプリケーションマネージャ」ではなく、クイック検索「外部クライアントアプリケーションマネージャー」にあります。
A-4. 鍵の発行と受け渡し
- 作成したアプリの「設定」タブ → OAuth設定 →「コンシューマ鍵と秘密」ボタン(操作者のメールに確認コードが届きます)
- 表示された「コンシューマ鍵」と「コンシューマの秘密」の2つを、社内の安全な方法(1Password共有など)で担当者へ。チャットやメール平文での送付は避けてください
A-5. あわせて伝えていただきたい情報
- 設定 → クイック検索「私のドメイン」→「現在の 私のドメイン の URL」の値(
xxxx.my.salesforce.com形式)
ブラウザのアドレスバーに出る
xxxx.lightning.force.com では動きません。必ず my.salesforce.com 形式のものをお願いします。情シス側の作業はここまでです。ありがとうございます。
B担当者側の作業(参考・約10分)
- Googleスプレッドシートを新規作成 → 拡張機能 → Apps Script → 下のコードを全文貼り付けて保存
- シートを再読み込み → メニュー「Salesforce」→「初期設定」で、A-5のURL・A-4の鍵と秘密を入力
- 「Salesforce」→「接続テスト」→「接続OK!」を確認
- 「レポート一覧シートを作る」→「SF設定」シートのA2に対象レポートのID(レポートを開いたURL内の
00Oで始まる英数字)を記入 - 「全レポート更新」→ 結果列に「✅ n件」が出れば完了。「毎朝の自動更新をON」で定時実行も可能
対象レポートの条件: 形式が「表形式」で、列に「レコードID」(取引先ID等)が含まれていること。
貼り付けるコード全文(クリックで開く)
/**
* SFレポート全件くん v1 (商品版)
* Salesforceのレポートを2000件制限なしでスプレッドシートに自動取得する
*
* 特徴:
* - 鍵はコードに書かない(初期設定ダイアログで入力→ユーザーのプロパティ領域に保存)
* - 複数レポート対応(「SF設定」シートに1行=1レポート)
* - 定時自動更新(毎朝メニューからON/OFF)
* 仕組み: レポートをID列で昇順に並べ、「前回の最後のIDより後ろ」を2000件ずつ取り続ける
*/
const API_VER = '/services/data/v60.0';
const PAGE_SIZE = 2000;
const MAX_PAGES = 500; // 安全弁(最大100万行)
const CONFIG_SHEET = 'SF設定';
/** シートを開いたときにメニューを追加 */
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Salesforce')
.addItem('全レポート更新', 'refreshAll')
.addSeparator()
.addItem('初期設定(接続情報の登録)', 'setupCredentials')
.addItem('レポート一覧シートを作る', 'createConfigSheet')
.addSeparator()
.addItem('毎朝の自動更新をON', 'enableDailyTrigger')
.addItem('自動更新をOFF', 'disableDailyTrigger')
.addItem('接続テスト', 'testConnection')
.addToUi();
}
/** ── 初期設定 ─────────────────────────────── */
/** 接続情報をダイアログで聞いてユーザープロパティに保存(コードに鍵を残さない) */
function setupCredentials() {
const ui = SpreadsheetApp.getUi();
const ask = function (title, hint, current) {
const res = ui.prompt(title, hint + (current ? '\n(現在: 設定済み。空のまま OK で変更しない)' : ''), ui.ButtonSet.OK_CANCEL);
if (res.getSelectedButton() !== ui.Button.OK) throw new Error('初期設定を中断しました');
return res.getResponseText().trim();
};
const props = PropertiesService.getUserProperties();
const cur = getCreds_(true);
const domain = ask('1/3 SalesforceのURL',
'例: https://xxxx.my.salesforce.com\n(xxxx.lightning.force.com の場合は my.salesforce.com に読み替え)', cur.domain);
const id = ask('2/3 コンシューマ鍵', '接続アプリケーションの「コンシューマ鍵」を貼り付け', cur.id);
const secret = ask('3/3 コンシューマの秘密', '接続アプリケーションの「コンシューマの秘密」を貼り付け', cur.secret);
if (domain) props.setProperty('SF_DOMAIN', domain.replace(/\/+$/, ''));
if (id) props.setProperty('SF_CLIENT_ID', id);
if (secret) props.setProperty('SF_CLIENT_SECRET', secret);
ui.alert('保存しました。「Salesforce → 接続テスト」で確認してください。\n※鍵はあなたのGoogleアカウントのプロパティ領域にだけ保存され、シートを共有しても他人には見えません。');
}
function getCreds_(allowEmpty) {
const p = PropertiesService.getUserProperties();
const c = {
domain: p.getProperty('SF_DOMAIN') || '',
id: p.getProperty('SF_CLIENT_ID') || '',
secret: p.getProperty('SF_CLIENT_SECRET') || '',
};
if (!allowEmpty && (!c.domain || !c.id || !c.secret)) {
throw new Error('接続情報が未設定です。メニュー「Salesforce → 初期設定」から登録してください。');
}
return c;
}
/** レポート一覧シート(SF設定)の雛形を作る */
function createConfigSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sh = ss.getSheetByName(CONFIG_SHEET);
if (!sh) sh = ss.insertSheet(CONFIG_SHEET);
if (sh.getLastRow() === 0) {
sh.getRange(1, 1, 1, 5).setValues([[
'レポートID(00Oで始まる)', '出力シート名', 'ID列(通常は空=自動検出)', '最終更新', '結果']]);
sh.getRange(2, 1, 1, 2).setValues([['00Oxxxxxxxxxxxxxxx', 'SFレポート1']]);
sh.setFrozenRows(1);
sh.autoResizeColumns(1, 5);
}
ss.setActiveSheet(sh);
SpreadsheetApp.getUi().alert(
'「' + CONFIG_SHEET + '」シートに取得したいレポートを1行ずつ書いてください。\n' +
'レポートIDは、Salesforceでレポートを開いたときのURLにある 00O で始まる英数字です。');
}
/** ── 取得本体 ─────────────────────────────── */
/** SF設定シートの全レポートを順に更新(メニュー/トリガー共用) */
function refreshAll() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sh = ss.getSheetByName(CONFIG_SHEET);
if (!sh || sh.getLastRow() < 2) {
throw new Error('「' + CONFIG_SHEET + '」シートがありません。メニュー「Salesforce → レポート一覧シートを作る」から作成してください。');
}
const creds = getCreds_(false);
const token = getToken_(creds);
const rows = sh.getRange(2, 1, sh.getLastRow() - 1, 3).getValues();
let ok = 0, ng = 0;
rows.forEach(function (row, i) {
const reportId = String(row[0]).trim();
const sheetName = String(row[1]).trim() || ('SFレポート' + (i + 1));
const idColumn = String(row[2]).trim();
if (!reportId || reportId.indexOf('00O') !== 0) return; // 雛形行や空行はスキップ
const cell = sh.getRange(i + 2, 4, 1, 2);
try {
const n = fetchReport_(creds, token, reportId, sheetName, idColumn);
cell.setValues([[new Date(), '✅ ' + n + '件']]);
ok++;
} catch (e) {
cell.setValues([[new Date(), '❌ ' + String(e.message).slice(0, 200)]]);
ng++;
}
});
ss.toast('更新完了: 成功' + ok + '件 / 失敗' + ng + '件', 'Salesforce', 10);
}
/** 1レポートを全件取得してシートに書く。取得件数を返す */
function fetchReport_(creds, token, reportId, sheetName, idColumnPref) {
// 1) レポートの定義を取得
const desc = sfGet_(creds, token, API_VER + '/analytics/reports/' + reportId + '/describe');
const meta = desc.reportMetadata;
if (meta.reportFormat && meta.reportFormat !== 'TABULAR') {
throw new Error('表形式ではありません(形式: ' + meta.reportFormat + ')。レポート編集画面で「表形式」に変更してください。');
}
const cols = meta.detailColumns;
const colInfo = desc.reportExtendedMetadata.detailColumnInfo;
// 2) ページング用のID列(指定 or 自動検出)
let idCol = idColumnPref;
if (!idCol) {
idCol = cols.filter(function (c) {
return /(^|_|\.)ID$/i.test(c) || /\.Id$/.test(c);
})[0];
}
if (!idCol || cols.indexOf(idCol) === -1) {
const candidates = cols.map(function (c) {
return c + ' (' + (colInfo[c] ? colInfo[c].label : '?') + ')';
}).join(' / ');
throw new Error('ID列が見つかりません。レポートに「レコードID」列を足すか、SF設定のID列に次から1つ指定: ' + candidates);
}
const idIdx = cols.indexOf(idCol);
// 3) ID昇順に固定して2000件ずつ取り続ける
meta.sortBy = [{ sortColumn: idCol, sortOrder: 'Asc' }];
const baseFilters = meta.reportFilters || [];
const baseLogic = meta.reportBooleanFilter || null;
const rows = [];
let lastId = null;
for (let page = 0; page < MAX_PAGES; page++) {
const m = JSON.parse(JSON.stringify(meta));
if (lastId) {
m.reportFilters = baseFilters.concat([{ column: idCol, operator: 'greaterThan', value: lastId }]);
if (baseLogic) {
m.reportBooleanFilter = '(' + baseLogic + ') AND ' + m.reportFilters.length;
}
}
const result = sfPost_(creds, token,
API_VER + '/analytics/reports/' + reportId + '?includeDetails=true',
{ reportMetadata: m });
const fact = result.factMap['T!T'];
if (!fact) throw new Error('詳細行が取得できません。レポート形式が「表形式」か確認してください。');
const pageRows = (fact.rows || []).map(function (r) {
return r.dataCells.map(function (c) { return c.label; });
});
if (pageRows.length === 0) break;
rows.push.apply(rows, pageRows);
const lastCell = fact.rows[fact.rows.length - 1].dataCells[idIdx];
const v = lastCell.value;
lastId = (v && v.id) ? v.id : String(v);
if (pageRows.length < PAGE_SIZE) break;
}
// 4) シートに書き出し
const ss = SpreadsheetApp.getActiveSpreadsheet();
const out = ss.getSheetByName(sheetName) || ss.insertSheet(sheetName);
out.clearContents();
const header = cols.map(function (c) { return colInfo[c] ? colInfo[c].label : c; });
out.getRange(1, 1, 1, header.length).setValues([header]);
if (rows.length > 0) {
out.getRange(2, 1, rows.length, header.length).setValues(rows);
}
return rows.length;
}
/** ── 自動更新トリガー ─────────────────────── */
function enableDailyTrigger() {
disableDailyTrigger();
ScriptApp.newTrigger('refreshAll').timeBased().everyDays(1).atHour(6).create();
SpreadsheetApp.getUi().alert('毎朝6〜7時ごろに全レポートを自動更新します。');
}
function disableDailyTrigger() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'refreshAll') ScriptApp.deleteTrigger(t);
});
}
/** ── 接続まわり ───────────────────────────── */
function testConnection() {
const creds = getCreds_(false);
const token = getToken_(creds);
const me = sfGet_(creds, token, API_VER + '/');
SpreadsheetApp.getUi().alert('接続OK! Salesforceに繋がりました。');
}
/** 認証: クライアントログイン情報フローでアクセストークンを取る */
function getToken_(creds) {
const res = UrlFetchApp.fetch(creds.domain + '/services/oauth2/token', {
method: 'post',
payload: {
grant_type: 'client_credentials',
client_id: creds.id,
client_secret: creds.secret,
},
muteHttpExceptions: true,
});
const body = JSON.parse(res.getContentText());
if (!body.access_token) {
throw new Error('Salesforce認証に失敗しました。初期設定のURL・鍵・接続アプリの設定を確認してください。詳細: ' + res.getContentText());
}
return body.access_token;
}
function sfGet_(creds, token, path) { return sfFetch_(creds, token, path, null); }
function sfPost_(creds, token, path, payload) { return sfFetch_(creds, token, path, payload); }
function sfFetch_(creds, token, path, payload) {
const options = {
method: payload ? 'post' : 'get',
headers: { Authorization: 'Bearer ' + token },
muteHttpExceptions: true,
};
if (payload) {
options.contentType = 'application/json';
options.payload = JSON.stringify(payload);
}
const res = UrlFetchApp.fetch(creds.domain + path, options);
if (res.getResponseCode() >= 300) {
throw new Error('Salesforce APIエラー(' + res.getResponseCode() + '): ' + res.getContentText());
}
return JSON.parse(res.getContentText());
}
Cエラーが出たときの早見表
| エラー表示 | 原因 | 直し方 |
|---|---|---|
| API参照名に使用できるのは英数字のみ… | アプリ名が日本語でAPI参照名に自動流用された | API参照名を手で英数字に(A-1) |
| 有効な実行ユーザーを入力してください | 実行ユーザーが未入力/メールアドレスを入れた | 「ユーザー名」を入れる(A-3) |
| already exists | 既存アプリがあるのに「新規」から再作成した | 既存アプリを開いて編集(A-3) |
| SyntaxError: Unexpected token '<' … is not valid JSON | URLが lightning.force.com 形式 | my.salesforce.com 形式に(A-5) |
| invalid_grant: no client credentials user enabled | フロー有効化のチェックまたは実行ユーザーが未保存 | A-3をやり直して保存 |
| 認証エラーが直らない | 設定反映前(最大10分) | 数分待って再実行 |