1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

image.png

image.png

image.png

はじめに

こちらは 身の回りの困りごとを楽しく解決! by Works Human Intelligence Advent Calendar 2025 の23日目としての記事です!
(空いてたので埋めさせていただきました🙏)

兼、株式会社ビザスク - VisasQ Inc. Advent Calendar 2025 の23日目としての記事です!

きっかけ

の RSS/Feed と Slack の以下の機能を活用することで #log-エンジニアイベント というチャンネルに connpass からのイベント情報を流し続ける仕組みがありました。

しかし、エンジニアイベント という名前であるもののデザイナーや情シス向けのイベントも流れてくるので…

  • 所謂エンジニアイベントの概要に含まれそうな語彙でフィルタできる
  • 所謂エンジニアイベントではないイベントの概要に含まれそうな語彙で除外できる
  • せっかくなので、所謂デザイナーイベントや所謂情シスイベント、その他イベントを検知した場合は #log-デザイナーイベント (新規ch) #log-情シスイベント (新規ch) などへイベント情報を投稿する

以上を実現する仕組みを Slack の Webhook Workflow と Google Apps Script で作ってみました!

step1:投稿用SlackWebhookWorkflowの作成

トリガーは From a webhook

image.png

payload:channel_id

Data typeSlack channel ID を選んでください 📻️

image.png

payload:message

Data typeText を選んでください ✏️

image.png

チャンネルへの投稿ステップを追加

前述で設定した channel_id のチャンネルへ同じく前述で設定した message を投稿するよう設定してください 🚀

image.png

step2:フィルタ/除外/投稿先ch設定用スプレッドシートを作成

以下のスクリーンショットのフォーマットのスプレッドシートを作成してください!

  1. READMECONFIG シートを用意する
  2. README の B2 セルに step1 で作った Workflow の Webhook URL をコピペ
  3. CONFIG シートの列とその内容は以下のとおり
    1. A列: slack_channel_name
      1. チャンネル名を入力 (どのような名前でも動作に影響はございませんが何のチャンネルの設定かわかりやすいよう設けております)
    2. B列: slack_channel_url
      1. SlackのチャンネルURLを入力
      2. tips: 以下のようにコピーできます image.png
    3. C列: resent_pub_date
      1. 自動で更新されるので空欄で OK 👍️
    4. D列: filter_list (,区切り/OR扱い,含まれるもののみ投稿)
      1. 例: エンジニア, 開発, プログラミング
    5. E列: ignore_list (,区切り/OR扱い,含まれるものは除外)
      1. 例: (例えば エンジニア, 開発, プログラミング が含まれるイベントだけど、もくもく会 など作業する系のイベントは除外したい場合(登壇を見る/するイベントを優先して知りたい等の要望があった場合)) もくもく会, 勉強会 in 〇〇
slack_channel_name	slack_channel_url	resent_pub_date	filter_list (,区切り/OR扱い,含まれるもののみ投稿)	ignore_list (,区切り/OR扱い,含まれるものは除外)

README のシート

image.png

CONFIG のシート

image.png

step3:GoogleAppsScriptのコードをセットアップ

image.png

ペーストするコード

/**
 * スプレッドシートからWebhook URLを取得します
 * @returns {string} Webhook URL
 */
function getWebhookUrl() {
  console.log('WebhookのURLを取得を開始します');
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('README');
    const webhookUrl = sheet.getRange('B2').getValue().toString().trim();

    if (!webhookUrl.startsWith('https://')) {
      throw new Error('WebhookのURLが不正です');
    }

    console.log('WebhookのURL取得に成功しました');
    return webhookUrl;
  } catch (error) {
    console.error('WebhookのURL取得に失敗しました:', error);
    throw error;
  }
}

/**
 * CONFIGSシートから設定を取得します
 * @returns {Array<Object>} 設定の配列
 */
function getConfigs() {
  console.log('設定の取得を開始します');
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('CONFIGS');
    const data = sheet.getDataRange().getValues();

    // ヘッダー行を除外
    const configs = data.slice(1).map(row => ({
      channelName: row[0].toString().trim(),
      channelUrl: row[1].toString().trim(),
      recentPubDate: row[2].toString().trim(),
      filterList: row[3].toString().trim().split(',').map(item => item.trim()).filter(Boolean),
      ignoreList: row[4] ? row[4].toString().trim().split(',').map(item => item.trim()).filter(Boolean) : []
    }));

    console.log('設定の取得に成功しました');
    return configs;
  } catch (error) {
    console.error('設定の取得に失敗しました:', error);
    throw error;
  }
}

/**
 * フィードの内容をフィルタリングします
 * @param {Object} item - フィードアイテム
 * @param {Array<string>} filterList - フィルターリスト
 * @param {Array<string>} ignoreList - 除外リスト
 * @returns {boolean} 投稿すべきかどうか
 */
function shouldPostItem(item, filterList, ignoreList) {
  const text = (item.title + ' ' + item.summary).toLowerCase();

  // フィルターリストが空の場合は全て通過
  const passesFilter = !filterList.length || filterList.some(filter => text.includes(filter.toLowerCase()));

  // 除外リストに一致するものがあれば除外
  const shouldIgnore = ignoreList.length > 0 && ignoreList.some(ignore => text.includes(ignore.toLowerCase()));

  return passesFilter && !shouldIgnore;
}

/**
 * Slackにメッセージを投稿します
 * @param {string} webhookUrl - Webhook URL
 * @param {string} channelId - チャンネルID
 * @param {string} message - 投稿メッセージ
 */
function postToSlack(webhookUrl, channelId, message) {
  console.log('Slackへの投稿を開始します');
  try {
    const payload = {
      channel_id: channelId,
      message: message
    };

    const options = {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify(payload)
    };

    UrlFetchApp.fetch(webhookUrl, options);
    console.log('Slackへの投稿に成功しました');
  } catch (error) {
    console.error('Slackへの投稿に失敗しました:', error);
    throw error;
  }
}

/**
 * Slackにメッセージをリトライ付きで投稿します
 * @param {string} webhookUrl - Webhook URL
 * @param {string} channelId - チャンネルID
 * @param {string} message - 投稿メッセージ
 * @param {number} maxRetries - 最大リトライ回数(デフォルト3回)
 */
function postToSlackWithRetry(webhookUrl, channelId, message, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      postToSlack(webhookUrl, channelId, message);
      return;
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      // リトライごとに待機時間を増やす(指数バックオフ)
      const waitMs = 2000 * (i + 1);
      console.warn(`Slack送信失敗。${waitMs / 1000}秒後にリトライします... (${i + 1}回目)`);
      Utilities.sleep(waitMs);
    }
  }
}

/**
 * 最新のpubDateを更新します
 * @param {string} recentPubDate - 最新のpubDate
 * @param {number} row - 更新する行番号
 */
function updateRecentPubDate(recentPubDate, row) {
  console.log('最新のpubDateの更新を開始します');
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('CONFIGS');
    // 行は1から始まり、ヘッダー行があるため+2する
    sheet.getRange(row + 2, 3).setValue(recentPubDate);
    console.log('最新のpubDateの更新に成功しました');
  } catch (error) {
    console.error('最新のpubDateの更新に失敗しました:', error);
    throw error;
  }
}

/**
 * メイン実行関数
 */
function main() {
  console.log('処理を開始します');
  try {
    const webhookUrl = getWebhookUrl();
    const configs = getConfigs();
    const feed = getConnpassFeed();

    configs.forEach((config, index) => {
      console.log(`${config.channelName}の処理を開始します`);

      // チャンネルIDを取得
      const channelId = config.channelUrl.split('/').pop();

      // 最新の記事から処理
      const items = feed.entries.sort((a, b) =>
        new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()
      );

      let latestPubDate = config.recentPubDate;

      items.forEach(item => {
        // 既に投稿済みの記事はスキップ
        if (new Date(item.pubDate) <= new Date(config.recentPubDate)) {
          return;
        }

        // フィルター条件に一致しない、または除外リストに一致する記事はスキップ
        if (!shouldPostItem(item, config.filterList, config.ignoreList)) {
          return;
        }

        // メッセージを作成して投稿
        const message = `${item.title}\n${item.link}`;
        postToSlackWithRetry(webhookUrl, channelId, message);
        // 負荷軽減のために少し待機(2秒)
        Utilities.sleep(2000);

        // 最新のpubDateを更新
        if (!latestPubDate || new Date(item.pubDate) > new Date(latestPubDate)) {
          latestPubDate = item.pubDate;
        }
      });

      // 最新のpubDateをスプレッドシートに保存
      if (latestPubDate !== config.recentPubDate) {
        updateRecentPubDate(latestPubDate, index);
      }

      console.log(`${config.channelName}の処理が完了しました`);
    });

    console.log('全ての処理が完了しました');
  } catch (error) {
    console.error('処理中にエラーが発生しました:', error);
    throw error;
  }
}

function getConnpassFeed() {
  // Fetch XML from connpass feed
  const url = 'https://connpass.com/explore/ja.atom';
  const response = UrlFetchApp.fetch(url);
  const xml = response.getContentText();

  // Parse XML to document
  const document = XmlService.parse(xml);
  const root = document.getRootElement();

  // Get atom namespace
  const atomNS = root.getNamespace();

  // Create feed object
  const feed = {
    title: getElementText(root, 'title', atomNS),
    link: getElementAttribute(root.getChild('link', atomNS), 'href'),
    updated: getElementText(root, 'updated', atomNS),
    author: {
      name: getElementText(root.getChild('author', atomNS), 'name', atomNS)
    },
    subtitle: getElementText(root, 'subtitle', atomNS),
    entries: []
  };

  // Get all entries
  const entries = root.getChildren('entry', atomNS);

  // Convert each entry to JSON structure
  entries.forEach(entry => {
    feed.entries.push({
      title: getElementText(entry, 'title', atomNS),
      link: getElementAttribute(entry.getChild('link', atomNS), 'href'),
      pubDate: getElementText(entry, 'published', atomNS),
      updated: getElementText(entry, 'updated', atomNS),
      id: getElementText(entry, 'id', atomNS),
      summary: getElementText(entry, 'summary', atomNS)
    });
  });

  return feed;
}

// Helper function to safely get element text
function getElementText(element, childName, namespace) {
  const child = element.getChild(childName, namespace);
  return child ? child.getText() : '';
}

// Helper function to safely get element attribute
function getElementAttribute(element, attributeName) {
  return element ? element.getAttribute(attributeName)?.getValue() || '' : '';
}

定期実行トリガーの設定

  1. main
  2. 時間主導型
  3. 分ベースのタイマー
  4. 15分おき

15分おき...SlackのRSS/Feedリーダ(冒頭で紹介したもの)も内部的には15分おきらしいのでそれに寄せました!

image.png

※このようにスプレッドシートやコード、トリガーを設定し保存をすると、スプレッドシートの読み書きや外部アクセス(connpass)の権限の承諾のポップアップが表示されますが、動作のために必要なので承諾を押してください 🙏

これらのステップで設定完了です!お疲れ様でした!!!
上手くできた場合、冒頭に掲載した3枚のスクリーンショットのようにイベント情報がフィルタ/除外されて Slack のチャンネルへ投稿されるかと思います!!!!!

さいごに

以上、「SlackとGASで作るフィルタ/除外機能付きRSS/Feedリーダ(connpass用)」でした!
Slack の RSS/Feed 機能で購読したものを読み取って仕分けるアプローチも考えたのですが、読み取りのために Slack Workflow ではなく Slack API Token も必要になってしまうので、XMLのfetchXMLのparse も GAS 上でやらせてみたら動いたので良かったです 💪
また、設定画面としてスプレッドシートを使うようにしたので、みんなで投稿先チャンネル、フィルタ、除外の設定を管理者(筆者)へ依頼する必要なく更新できるように作れて良かったです ✨️
ここまでご覧くださりありがとうございました!(どなたかのご参考になれば幸いです!)

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?