【第3回】Xポスト自動取得|スプレッドシート保存のGAS側の解説

Dify

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

【第2回】Dify×GAS×X APIでポストを自動取得する方法
XポストをDifyとGASを使って自動で取得する方法について紹介しています。また、Difyでのつまずきやすいポイントなど、今後も役に立つDify設計の注意点なども紹介しているのでぜひ参考にしてください。

今回はその続きとして、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 プロジェクトの作成手順

スクリプトエディタを開く

  1. Googleスプレッドシートを開く
  2. 上部メニューの 拡張機能 をクリックする
  3. 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で使う usernameuser_id を返します。

これにより、監視対象をON/OFFしやすくなります。

saveNewPosts_ で重複除外して保存する

保存時は、次の順で処理しています。

  1. 候補データを整形する
  2. 既存の post_id 一覧を取得する
  3. 今回の候補と照合する
  4. 新規投稿だけを pending で保存する

ここで重複除外をしているため、同じ収集フローを再実行しても、同じ投稿を何度も追加しにくくなります。

Script Properties の設定

Apps Script側では、認証情報やスプレッドシートIDを Script Properties に保存します。

今回必要なのは次の2つです。

キー名 内容
SPREADSHEET_ID 接続先のスプレッドシートID
SHARED_SECRET DifyとGAS間で共通に使う秘密文字列

設定手順

  1. Apps Script 画面の左側メニューから プロジェクトの設定 を開く
  2. スクリプト プロパティ を探す
  3. スクリプト プロパティを追加 をクリックする
  4. SPREADSHEET_IDSHARED_SECRET を登録する

ここで設定した値は、コード内で getRequiredProperty_() から読み込みます。

初期セットアップの実行方法

何を実行するか

最初に実行するのは setupProject です。

実行手順

  1. Apps Script 画面上部の関数プルダウンを開く
  2. setupProject を選ぶ
  3. 実行 をクリックする

初回だけ必要な許可

初めて実行するとGoogleの許可画面が出ることがあります。

その場合は次の順で進めます。

  1. 使うGoogleアカウントを選ぶ
  2. 確認画面が出たら内容を確認する
  3. 許可 を押す

これでシート作成とヘッダー整備が実行されます。

完了確認

スプレッドシート側に、次の2シートができていれば完了です。

  • accounts
  • collected_posts

Webアプリとして公開する手順

DifyからGASを呼ぶには、Apps ScriptをWebアプリとしてデプロイする必要があります。

デプロイ手順

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

完了確認

デプロイ後に表示されるURLが、Difyから呼び出すGASのURLになります。

コードを変更した後は、保存だけでは反映されません。
再デプロイが必要です。

Difyから保存するときの確認ポイント

Dify側から save_new_posts を呼んだ時は、次の項目を見ると状態を判断しやすいです。

見たいこと
saved_count 新規保存された件数
duplicate_count 重複として除外された件数
saved_posts 保存された投稿一覧
batch_id 今回の保存単位
たとえば、再実行時に saved_count: 0 かつ duplicate_count が増えていれば、重複除外が効いていると判断できます。

保存状態を pending にしている理由

今回の保存時点では、投稿の状態を pending にしています。

これは、取得した投稿をあとで別用途に流しやすくするためです。
たとえば、後続で次のような処理につなげやすくなります。

  • 要約や分類
  • WordPress下書き化
  • 内容確認後の状態更新

このように、保存と同時に最終状態へ決め打ちせず、後続処理とつなげやすい中間状態を持たせています。

私のケースでは、取得したポストをあとでWPへの投稿に使っています。

実装時に整理しておきたい注意点

配列データが文字列で届くことがある

Difyから配列を送ったつもりでも、GAS側では文字列として届くことがあります。
そのため、今回のコードでは normalizeCandidates_() を用意し、配列でも文字列でも受けられるようにしています。

こうしておくと、Dify側の送信形が多少変わっても吸収しやすくなります。

保存件数が0の時は送信データの形も確認する

saved_count が0で、重複も0の時は、そもそも候補データが渡っていない可能性があります。
この場合は、Dify側のHTTP Request bodyや、request_body_json の組み立て方を見直す方が整理しやすいです。

コード変更後は再デプロイが必要

Apps Scriptでは、コードを保存しただけではWebアプリ側に反映されません。
デバッグ時に古い挙動が続く場合は、保存ではなくデプロイ更新漏れを疑うと切り分けやすくなります。

まとめ

今回は、Difyで取得したX投稿をGASで受け取り、スプレッドシートへ保存する方法を紹介しました。

ここは、単に保存先を用意するだけでなく、監視対象の管理、重複除外、保存状態の整理まで含めて土台を作っています。こうした部分を先に整えておくと、あとで要約、分類、記事化などへ広げやすくなります。

今回の3本で、指定したXアカウントの投稿を取得し、スプレッドシートへためる仕組みの全体を一通りまとめました。業務効率化のヒントが少しでも得られたらうれしいです。

【第1回】Xポストを自動取得!Dify×GASでスプレッドシートに保存する方法
Dify×GAS×X APIで任意のアカウントのポストを自動取得してスプレッドシートに保存するツールを作成しました。ここでは、全体像とどういった目的のツールなのかについて紹介しています。ツール全体の設計については、この記事シリーズの2でお話しています。
【第2回】Dify×GAS×X APIでポストを自動取得する方法
XポストをDifyとGASを使って自動で取得する方法について紹介しています。また、Difyでのつまずきやすいポイントなど、今後も役に立つDify設計の注意点なども紹介しているのでぜひ参考にしてください。
  • 競合アカウントを分析したい
  • SNS運用の業務を効率化したい
  • 自社の運用に合う形で補助の仕組みを考えたい

という方がいれば、無料相談・お問い合わせからお気軽にご連絡ください。
今の運用に合わせて、どこまで自動化するのがちょうど良いか、一緒に整理できればと思います。

問い合わせフォームはこちら

コメント

タイトルとURLをコピーしました