モチベーション
Qiita Organizationに記事投稿されたとき社内へ通知できるような仕組みが欲しかった。
弊社ではDiscordサーバーも運用しているのでDiscord Webhook + GASで作ってみる。
以下の記事を参考にさせていただきました。
QiitaのOrganizationに記事を投稿すると自動でDiscordに通知させる
やってみる
コード作成(Gemini CLI)
新しくGASプロジェクトを作成し以下のファイルを作成。
// --- 設定 ---
const CONFIG = {
DISCORD_WEBHOOK_URL: PropertiesService.getScriptProperties().getProperty('DISCORD_WEBHOOK_URL'),
RSS_FEED_URL: PropertiesService.getScriptProperties().getProperty('RSS_FEED_URL'),
LAST_CHECK_KEY: 'lastCheck',
QIITA_COLOR: 5620992,
};
// --- メイン処理 ---
/**
* RSSフィードをチェックし、新着記事があればDiscordに通知する。
* この関数はトリガーによる定期実行を想定する。
*/
function checkQiitaFeed() {
try {
const lastCheckDate = getLastCheckDate();
const allEntries = getFeedEntries(CONFIG.RSS_FEED_URL);
const newEntries = allEntries.filter(entry => {
const publishedDate = new Date(entry.getChild('published', getAtomNamespace()).getText());
return publishedDate > lastCheckDate;
});
if (newEntries.length > 0) {
Logger.log(`${newEntries.length}件の新着記事があります。`);
newEntries.forEach(entry => {
const { title, link, author, summary } = extractEntryDetails(entry);
sendDiscordNotification(title, link, author, summary);
});
} else {
Logger.log('新着記事はありませんでした。');
}
updateLastCheck();
} catch (e) {
// e.stack を利用して、より詳細なエラー情報を記録する
Logger.log(`処理全体でエラーが発生しました: ${e.stack || e.message}`);
}
}
// --- ヘルパー関数 ---
/**
* Atomフィードの名前空間を取得する。
* @returns {GoogleAppsScript.XML_Service.Namespace}
*/
function getAtomNamespace() {
return XmlService.getNamespace('http://www.w3.org/2005/Atom');
}
/**
* RSSフィードを取得し、記事エントリの配列を返す。
* @param {string} url - RSSフィードのURL
* @returns {Array<GoogleAppsScript.XML_Service.Element>} - 記事エントリの配列
*/
function getFeedEntries(url) {
try {
const response = UrlFetchApp.fetch(url);
const xml = response.getContentText();
const document = XmlService.parse(xml);
const root = document.getRootElement();
return root.getChildren('entry', getAtomNamespace());
} catch (e) {
Logger.log(`RSSフィードの取得または解析に失敗しました: ${e.message}`);
// エラーを再スローして、呼び出し元で処理を中断させる
throw new Error('RSSフィードの処理に失敗しました。');
}
}
/**
* 記事エントリから詳細情報を抽出する。
* @param {GoogleAppsScript.XML_Service.Element} entry - 記事エントリ
* @returns {{title: string, link: string, author: string, summary: string}}
*/
function extractEntryDetails(entry) {
const namespace = getAtomNamespace();
const title = entry.getChild('title', namespace).getText();
const link = entry.getChild('link', namespace).getAttribute('href').getValue();
const author = entry.getChild('author', namespace).getChild('name', namespace).getText();
// 概要は<content>タグからのみ取得する
let summary = '';
const contentElement = entry.getChild('content', namespace);
if (contentElement) {
const rawText = contentElement.getText();
// HTMLタグを削除し、改行をスペースに置換してプレーンテキスト化する
summary = rawText.replace(/<[^>]+>/g, '').replace(/\n/g, ' ').trim();
// 概要が長すぎる場合にDiscordの制限に引っかからないよう、200文字に丸める
if (summary.length > 200) {
summary = summary.substring(0, 200) + '...';
}
}
return { title, link, author, summary };
}
/**
* 前回のチェック日時を取得する。初回実行時は現在時刻を返す。
* @returns {Date}
*/
function getLastCheckDate() {
const lastCheck = PropertiesService.getScriptProperties().getProperty(CONFIG.LAST_CHECK_KEY);
Logger.log('前回チェック日時: ' + (lastCheck ? Utilities.formatDate(new Date(lastCheck), 'JST', 'yyyy/MM/dd HH:mm:ss') : '初回実行'));
// 初回実行時は、スクリプト実行後の記事のみを通知対象とする
return lastCheck ? new Date(lastCheck) : new Date();
}
/**
* 今回の実行日時をプロパティに保存する。
*/
function updateLastCheck() {
PropertiesService.getScriptProperties().setProperty(CONFIG.LAST_CHECK_KEY, new Date().toISOString());
}
/**
* Discord通知用のペイロードを作成する。
* @param {string} title - 記事のタイトル
* @param {string} link - 記事のURL
* @param {string} author - 記事の投稿者
* @param {string} summary - 記事の概要
* @returns {object} - Discord API用のペイロード
*/
function createDiscordPayload(title, link, author, summary) {
return {
embeds: [{
author: { name: "Qiita" },
title: title,
url: link,
description: summary, // 概要をdescriptionとして追加
footer: { text: `author: @${author}` },
color: CONFIG.QIITA_COLOR,
}]
};
}
/**
* Discordに通知を送信する。
* @param {string} title - 記事のタイトル
* @param {string} link - 記事のURL
* @param {string} author - 記事の投稿者
* @param {string} summary - 記事の概要
*/
function sendDiscordNotification(title, link, author, summary) {
try {
const payload = createDiscordPayload(title, link, author, summary);
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true, // HTTPエラー発生時もレスポンスを取得する
};
const response = UrlFetchApp.fetch(CONFIG.DISCORD_WEBHOOK_URL, options);
const responseCode = response.getResponseCode();
if (responseCode >= 200 && responseCode < 300) {
Logger.log(`通知を送信しました: ${title}`);
} else {
// 失敗した場合は、ステータスコードとレスポンスボディをログに出力する
Logger.log(`Discordへの通知送信に失敗しました。ステータス: ${responseCode}, レスポンス: ${response.getContentText()}`);
}
} catch (e) {
Logger.log(`Discord通知の送信中に予期せぬエラーが発生しました: ${e.stack || e.message}`);
}
}
スクリプト プロパティの設定
key | value |
---|---|
DISCORD_WEBHOOK_URL | 通知を送りたいDiscordのテキストチャンネルで事前に作成したWebhook URL |
RSS_FEED_URL | Organization URLの後ろに/activities.atom をつけたもの。例:https://qiita.com/organizations/<組織名>/activities.atom |
トリガー設定
時間ベースのトリガーを1時間にセットして完了。
記事を投稿し、最大1時間以内に通知が来たら成功。
課題
Discordの場合、Organizationに関連付けられた記事がなぜか埋め込みが表示されなかったので、それっぽくなるようにembedsを設定した。LINEなど他のサービスでは問題なく埋め込み表示されるのでDiscord側の問題と思われる。
もし解決策をご存じの方がいればコメントで教えていただけると非常に助かります!
自動で埋め込み表示されないのでembedsで作成 ↓
function createDiscordPayload(title, link, author, summary) {
return {
embeds: [{
author: { name: "Qiita" },
title: title,
url: link,
description: summary, // 概要をdescriptionとして追加
footer: { text: `author: @${author}` },
color: CONFIG.QIITA_COLOR,
}]
};
}