前回は、Dify側でX投稿を取得し、保存しやすい形に整えるところまでを紹介しました。

今回はその続きとして、GASでデータを受け取り、スプレッドシートへ保存する部分をまとめます。
ここでは単純に行を追加するだけではなく、監視対象アカウントの一覧取得、重複除外、保存状態の管理まで含めて整理しています。Difyで取得したデータを実務で扱いやすい形にするうえでとても重要な土台となる部分です。
この記事で紹介する内容
- GASを使う理由
- スプレッドシートの構成
- Apps Scriptプロジェクトの作成手順
- GASの完成コード
- Webhookとしてデプロイする手順
- Difyから保存したときの確認ポイント
- 重複除外と保存状態の考え方
GASを保存処理に使う理由
紹介している構成では、X APIの呼び出し自体はDify側で行い、保存や一覧管理はGAS側で受け持っています。
この役割分担にしている理由は、次の3点です。
- 監視対象アカウントをスプレッドシートで管理しやすい
- 保存済み投稿との重複判定をしやすい
- 取得データをあとで別の処理へつなげやすい
Difyだけで取得から保存までを抱え込むより、保存に近い処理はGASとスプレッドシートへ寄せた方が整理しやすいという理由です。
スプレッドシートの構成
![]()
今回使うシートは2つです。
| シート名 | 役割 |
|---|---|
| accounts | 監視対象アカウント一覧の管理 |
| collected_posts | 取得した投稿の保存 |
accounts シートの列

| 列名 | 内容 |
|---|---|
| enabled | 有効かどうか |
| username | Xのユーザー名 |
| user_id | XのユーザーID |
| note | 任意のメモ |
collected_posts シートの列

| 列名 | 内容 |
|---|---|
| batch_id | 保存単位の識別子 |
| post_id | X投稿ID |
| username | Xユーザー名 |
| user_id | XユーザーID |
| post_url | 投稿URL |
| created_at | 投稿日時 |
| saved_at | 保存日時 |
| status | 保存後の状態 |
| error_message | 必要に応じたエラー記録 |
Apps Script プロジェクトの作成手順
スクリプトエディタを開く

- Googleスプレッドシートを開く
- 上部メニューの 拡張機能 をクリックする
- Apps Script をクリックする
これでApps Scriptの編集画面が開きます。
コードを貼り付ける場所

左側にある コード.gs を開き、その中身を今回のコードに置き換えます。
すでに何か初期コードが入っている場合は、削除してから貼り付けてください。
保存方法
コードを貼り付けたら、上部の保存アイコンを押すか、Ctrl + S で保存します。
ここで保存しても、まだWebアプリとしては公開されていません。
後の手順でデプロイまで行って、はじめてDifyから呼び出せるようになります。
GASの完成コード
このコードは、次の役割をまとめて持っています。
- 監視対象アカウント一覧を返す
- Difyから受け取った投稿候補を保存する
- 重複投稿を除外する
- スプレッドシートのヘッダーを自動で整える
const SHEET_NAMES = {
ACCOUNTS: 'accounts',
COLLECTED: 'collected_posts',
};
const HEADERS = {
ACCOUNTS: ['enabled', 'username', 'user_id', 'note'],
COLLECTED: [
'batch_id',
'post_id',
'username',
'user_id',
'post_url',
'created_at',
'saved_at',
'status',
'error_message',
],
};
function doGet() {
return jsonResponse_({
ok: true,
service: 'x-post-collector-bridge',
now: new Date().toISOString(),
message: 'Web App is running.',
});
}
function doPost(e) {
try {
const payload = parseRequestBody_(e);
authenticate_(payload);
const action = String(payload.action || '').trim();
if (action === 'get_accounts') {
return jsonResponse_(getAccounts_());
}
if (action === 'save_new_posts') {
return jsonResponse_(saveNewPosts_(payload));
}
throw new Error('Unsupported action: ' + action);
} catch (error) {
return jsonResponse_({
ok: false,
error: error.message || String(error),
stack: error && error.stack ? String(error.stack) : '',
});
}
}
/**
* 初期セットアップ
* 1回だけ手動実行
*/
function setupProject() {
const ss = openSpreadsheet_();
ensureSheetWithHeaders_(ss, SHEET_NAMES.ACCOUNTS, HEADERS.ACCOUNTS);
ensureSheetWithHeaders_(ss, SHEET_NAMES.COLLECTED, HEADERS.COLLECTED);
Logger.log('setupProject completed');
}
/**
* Action: get_accounts
*/
function getAccounts_() {
const ss = openSpreadsheet_();
const sheet = getSheetOrThrow_(ss, SHEET_NAMES.ACCOUNTS);
const rows = readObjectsFromSheet_(sheet);
const accounts = rows
.filter(row => isTrueLike_(row.enabled))
.map(row => ({
username: normalizeString_(row.username),
user_id: normalizeString_(row.user_id),
}))
.filter(row => row.username !== '' && row.user_id !== '');
return {
ok: true,
accounts,
accounts_count: accounts.length,
};
}
/**
* Action: save_new_posts
* candidates を受け取り、post_id 重複を除外して pending 保存
*/
function saveNewPosts_(payload) {
const ss = openSpreadsheet_();
const sheet = getSheetOrThrow_(ss, SHEET_NAMES.COLLECTED);
const maxPosts = sanitizeMax_(payload.max_posts_total, 30);
const candidates = normalizeCandidates_(payload.candidates);
const existingPostIds = getExistingPostIds_(sheet);
const seenInThisRequest = new Set();
const sortedCandidates = candidates
.map(item => ({
post_id: normalizeString_(item.post_id),
username: normalizeString_(item.username),
user_id: normalizeString_(item.user_id),
post_url: normalizeString_(item.post_url),
created_at: normalizeString_(item.created_at),
}))
.filter(item =>
item.post_id !== '' &&
item.username !== '' &&
item.user_id !== '' &&
item.post_url !== ''
)
.sort((a, b) => toTime_(b.created_at) - toTime_(a.created_at));
const batchId = Utilities.getUuid();
const savedAt = new Date().toISOString();
const rowsToAppend = [];
const savedPosts = [];
let duplicateCount = 0;
for (const item of sortedCandidates) {
if (savedPosts.length >= maxPosts) break;
if (existingPostIds.has(item.post_id) || seenInThisRequest.has(item.post_id)) {
duplicateCount += 1;
continue;
}
rowsToAppend.push([
batchId,
item.post_id,
item.username,
item.user_id,
item.post_url,
item.created_at,
savedAt,
'pending',
'',
]);
savedPosts.push({
post_id: item.post_id,
username: item.username,
user_id: item.user_id,
post_url: item.post_url,
created_at: item.created_at,
});
seenInThisRequest.add(item.post_id);
}
if (rowsToAppend.length > 0) {
appendRows_(sheet, rowsToAppend);
}
return {
ok: true,
batch_id: rowsToAppend.length > 0 ? batchId : '',
saved_posts: savedPosts,
saved_count: savedPosts.length,
duplicate_count: duplicateCount,
};
}
function authenticate_(payload) {
const expected = getRequiredProperty_('SHARED_SECRET');
const received = normalizeString_(payload.secret);
if (!expected || received !== expected) {
throw new Error('Unauthorized.');
}
}
function parseRequestBody_(e) {
if (!e || !e.postData || !e.postData.contents) {
return {};
}
return JSON.parse(e.postData.contents);
}
function openSpreadsheet_() {
const spreadsheetId = getRequiredProperty_('SPREADSHEET_ID');
return SpreadsheetApp.openById(spreadsheetId);
}
function getRequiredProperty_(key) {
const value = PropertiesService.getScriptProperties().getProperty(key);
if (!value) {
throw new Error('Missing Script Property: ' + key);
}
return value;
}
function getSheetOrThrow_(ss, sheetName) {
const sheet = ss.getSheetByName(sheetName);
if (!sheet) {
throw new Error('Sheet not found: ' + sheetName);
}
return sheet;
}
function ensureSheetWithHeaders_(ss, sheetName, headers) {
let sheet = ss.getSheetByName(sheetName);
if (!sheet) {
sheet = ss.insertSheet(sheetName);
}
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
return;
}
const current = sheet.getRange(1, 1, 1, headers.length).getValues()[0];
const same = headers.every((header, index) => String(current[index] || '') === header);
if (!same) {
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
}
}
function readObjectsFromSheet_(sheet) {
const values = sheet.getDataRange().getValues();
if (values.length <= 1) {
return [];
}
const headers = values[0].map(v => String(v));
return values.slice(1).map((row, index) => {
const obj = { __rowNumber: index + 2 };
headers.forEach((header, i) => {
obj[header] = row[i];
});
return obj;
});
}
function appendRows_(sheet, rows) {
if (!rows || rows.length === 0) return;
const startRow = sheet.getLastRow() + 1;
sheet.getRange(startRow, 1, rows.length, rows[0].length).setValues(rows);
}
function getExistingPostIds_(sheet) {
const rows = readObjectsFromSheet_(sheet);
const set = new Set();
for (const row of rows) {
const postId = normalizeString_(row.post_id);
if (postId) {
set.add(postId);
}
}
return set;
}
function normalizeCandidates_(value) {
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
const text = value.trim();
if (!text) return [];
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
}
function normalizeString_(value) {
if (value === null || value === undefined) return '';
return String(value).trim();
}
function isTrueLike_(value) {
if (value === true) return true;
const text = normalizeString_(value).toLowerCase();
return text === 'true' || text === '1' || text === 'yes';
}
function sanitizeMax_(value, defaultValue) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return defaultValue;
return Math.max(1, Math.min(30, Math.floor(n)));
}
function toTime_(value) {
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
function jsonResponse_(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
このコードで行っていること
doPost でアクションごとに処理を分ける
Difyからのリクエストは、action の値によって処理を分けています。
get_accounts:監視対象アカウント一覧を返すsave_new_posts:投稿候補を受け取り保存する
この形にしておくと、1つのWebアプリURLで複数の役割をまとめやすくなります。
setupProject でシートの初期状態を整える
setupProject() は最初に1回だけ手動実行する関数です。
必要なシートが無ければ作成し、ヘッダー行も整えます。
最初の準備をコードで揃えておくと、列名のズレやシート作成漏れが起きにくくなります。
getAccounts_ で有効なアカウントだけ返す
accounts シートから enabled が有効な行だけを取り出し、Difyで使う username と user_id を返します。
これにより、監視対象をON/OFFしやすくなります。
saveNewPosts_ で重複除外して保存する
保存時は、次の順で処理しています。
- 候補データを整形する
- 既存の
post_id一覧を取得する - 今回の候補と照合する
- 新規投稿だけを
pendingで保存する
ここで重複除外をしているため、同じ収集フローを再実行しても、同じ投稿を何度も追加しにくくなります。
Script Properties の設定
Apps Script側では、認証情報やスプレッドシートIDを Script Properties に保存します。
今回必要なのは次の2つです。
| キー名 | 内容 |
|---|---|
| SPREADSHEET_ID | 接続先のスプレッドシートID |
| SHARED_SECRET | DifyとGAS間で共通に使う秘密文字列 |
設定手順


- Apps Script 画面の左側メニューから プロジェクトの設定 を開く
- スクリプト プロパティ を探す
- スクリプト プロパティを追加 をクリックする
SPREADSHEET_IDとSHARED_SECRETを登録する
ここで設定した値は、コード内で getRequiredProperty_() から読み込みます。
初期セットアップの実行方法
何を実行するか

最初に実行するのは setupProject です。
実行手順
- Apps Script 画面上部の関数プルダウンを開く
- setupProject を選ぶ
- 実行 をクリックする
初回だけ必要な許可
初めて実行するとGoogleの許可画面が出ることがあります。
その場合は次の順で進めます。
- 使うGoogleアカウントを選ぶ
- 確認画面が出たら内容を確認する
- 許可 を押す
これでシート作成とヘッダー整備が実行されます。
完了確認
スプレッドシート側に、次の2シートができていれば完了です。
accountscollected_posts
Webアプリとして公開する手順
DifyからGASを呼ぶには、Apps ScriptをWebアプリとしてデプロイする必要があります。
デプロイ手順

- Apps Script画面右上の デプロイ をクリックする
- 新しいデプロイ を選ぶ
- 種類で ウェブアプリ を選ぶ
- 説明を入力する
- 実行ユーザー は自分を選ぶ
- アクセスできるユーザー は用途に合わせて設定する
- デプロイ をクリックする
完了確認

デプロイ後に表示されるURLが、Difyから呼び出すGASのURLになります。
Difyから保存するときの確認ポイント
Dify側から save_new_posts を呼んだ時は、次の項目を見ると状態を判断しやすいです。
| 値 | 見たいこと |
|---|---|
saved_count |
新規保存された件数 |
duplicate_count |
重複として除外された件数 |
saved_posts |
保存された投稿一覧 |
batch_id |
今回の保存単位 |
保存状態を pending にしている理由
今回の保存時点では、投稿の状態を pending にしています。
これは、取得した投稿をあとで別用途に流しやすくするためです。
たとえば、後続で次のような処理につなげやすくなります。
- 要約や分類
- WordPress下書き化
- 内容確認後の状態更新
このように、保存と同時に最終状態へ決め打ちせず、後続処理とつなげやすい中間状態を持たせています。
実装時に整理しておきたい注意点
配列データが文字列で届くことがある
Difyから配列を送ったつもりでも、GAS側では文字列として届くことがあります。
そのため、今回のコードでは normalizeCandidates_() を用意し、配列でも文字列でも受けられるようにしています。
こうしておくと、Dify側の送信形が多少変わっても吸収しやすくなります。
保存件数が0の時は送信データの形も確認する
saved_count が0で、重複も0の時は、そもそも候補データが渡っていない可能性があります。
この場合は、Dify側のHTTP Request bodyや、request_body_json の組み立て方を見直す方が整理しやすいです。
コード変更後は再デプロイが必要
まとめ
今回は、Difyで取得したX投稿をGASで受け取り、スプレッドシートへ保存する方法を紹介しました。
ここは、単に保存先を用意するだけでなく、監視対象の管理、重複除外、保存状態の整理まで含めて土台を作っています。こうした部分を先に整えておくと、あとで要約、分類、記事化などへ広げやすくなります。
今回の3本で、指定したXアカウントの投稿を取得し、スプレッドシートへためる仕組みの全体を一通りまとめました。業務効率化のヒントが少しでも得られたらうれしいです。


- 競合アカウントを分析したい
- SNS運用の業務を効率化したい
- 自社の運用に合う形で補助の仕組みを考えたい
という方がいれば、無料相談・お問い合わせからお気軽にご連絡ください。
今の運用に合わせて、どこまで自動化するのがちょうど良いか、一緒に整理できればと思います。


コメント