GASでRSSを取得してブログの更新をBlueskyに投稿する
- 投稿した日
- 更新した日
- 書いたひと
- ひらたけ
ブログが更新されたら Bluesky へ自動で投稿するツールを作ろうと思い、先日 Google Apps Script を用いて実装 しました。しばらく動かしてみて、今のところちゃんと問題なく動いていそうなので、備忘録的な感じで中身について書いていこうかなと思います。
全体の流れとしては 「Google Apps Script から対象のブログの RSS フィードを取得」 → 「まだ Bluesky へ投稿していないものがあるかどうかの判定」 → 「Bluesky の API を実行して投稿」 といった感じです。
スプレッドシートをつくる
Google Apps Script を利用するため、Google スプレッドシートを作成します。この Google スプレッドシートには Bluesky へ投稿済みのブログ記事の URL を記録 していき、既に Bluesky へ投稿済みのブログ記事を 重複して投稿してしまわないようにするために使用 します。
シートの名前は任意ですが、ここでは「sheet」という名前で設定します。
RSS フィードを取得する
Google Apps Script を記述して、対象のブログの RSS フィードを取得します。スプレッドシートの画面の上部にある「拡張機能」から「Apps Script」を選択して Google Apps Script の画面を開きます。
左側のメニューから「エディタ」を選択するとプログラムを記述するエディタが表示されるので、ここに処理を書いていきます。以下記事を参考に、RSS フィードを取得する getRss
関数を実装します。
記述したコードは以下の通りです。getRss
関数に引数として RSS フィードの URL を与えると、RSS フィードに含まれる記事の配列が返ってきます。
/**
* RSSフィードを取得する
* @param url RSSフィードのURL
* @returns RSSフィードの結果の配列
*/
const getRss = (url = '') => {
if (url) {
const xml = UrlFetchApp.fetch(url).getContentText()
const document = XmlService.parse(xml)
const root = document.getRootElement()
const rss1 = root.getChildren('item', XmlService.getNamespace('http://purl.org/rss/1.0/'))
const atom = root.getChildren('entry', XmlService.getNamespace('http://www.w3.org/2005/Atom'))
if (rss1.length > 0) {
// RSS 1.0
return processingRSS10(root)
} else if (atom.length > 0) {
// Atom
return processingAtom(root)
} else {
// RSS 2.0
return processingRSS20(root)
}
} else {
return []
}
}
/**
* RSS 1.0 フィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingRSS10 = (xml) => {
const rss = XmlService.getNamespace('http://purl.org/rss/1.0/')
const dc = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/')
const feedItems = xml.getChildren('item', rss)
return feedItems.map((item) => {
const title = item.getChild('title', rss).getText()
const link = item.getChild('link', rss).getText()
const description = item.getChild('description',rss).getText()
return { title, link, description }
})
}
/**
* RSS 2.0 フィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingRSS20 = (xml) => {
const feedItems = xml.getChild('channel').getChildren('item')
return feedItems.map((item) => {
const title = item.getChild('title').getText()
const link = item.getChild('link').getText()
const description = item.getChild('description').getText()
return { title, link, description }
})
}
/**
* Atomフィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingAtom = (xml) => {
const atom = XmlService.getNamespace('http://www.w3.org/2005/Atom')
const feedItems = xml.getChildren('entry', atom)
return feedItems.map((item) => {
const title = item.getChild('title', atom).getText()
const link = item
.getChild('link', atom)
.getAttribute('href')
.getValue()
const description = item.getChild('summary', atom).getText()
return { title, link, description }
})
}
Bluesky にブログ記事のリンクを投稿する場合には「記事のタイトル」「記事の URL」「記事の概要(ディスクリプション)」が必要になります。そのため、title
と link
と description
の 3 つのキーを持つオブジェクトの配列を返り値としています。
投稿済みの記事を除外する
取得した RSS フィードに含まれる記事の中で、Bluesky へ投稿すべきものがあるかどうかをチェックする処理を実装します。先述の通り、既に投稿済みのものは Google スプレッドシートへ記録していく想定なので、取得した記事の配列からシートに存在しているものを除外します。
投稿済みの記事を除外する関数 filterNotNotifiedPost
へ、先ほど作成した getRss
関数を渡して、まだ Bluesky へ投稿していない記事の配列 posts
を生成します。
const main = () => {
/** スプレッドシート */
const spreadsheet = SpreadsheetApp.getActive()
/** シート */
const sheet = spreadsheet.getSheetByName('sheet')
/** ブログ記事一覧 */
const posts = filterNotNotifiedPost(sheet, getRss('RSSフィードのURL'))
}
/**
* 投稿済みのブログ記事を除外する
* @params sheet スプレッドシートのオブジェクト
* @params items ブログ記事の配列
* @returns 投稿済みのブログ記事を除外したブログ記事の配列
*/
const filterNotNotifiedPost = (sheet, items) => {
if (sheet.getLastRow()) {
const notified = sheet.getRange(1, 1, sheet.getLastRow(), 1).getValues().flat()
return items.filter((item) => !notified.includes(item.link))
} else {
return items
}
}
また、Bluesky へ投稿した記事を Google スプレッドシートへ書き込む関数 addNotifiedPostToSheet
も実装します。
const main = () => {
/** スプレッドシート */
const spreadsheet = SpreadsheetApp.getActive()
/** シート */
const sheet = spreadsheet.getSheetByName('sheet')
/** ブログ記事一覧 */
const posts = filterNotNotifiedPost(sheet, getRss('RSSフィードのURL'))
if (posts.length) {
posts.forEach((post) => {
addNotifiedPostToSheet(sheet, post.link)
})
}
}
/**
* 投稿済みのブログ記事をスプレッドシートへ追加する
* @params sheet スプレッドシートのオブジェクト
* @params url 追加するブログ記事のURL
*/
const addNotifiedPostToSheet = (sheet, url) => {
sheet.insertRowBefore(1)
sheet.getRange(1, 1).setValue(url)
}
Bluesky にテキストを投稿する
Bluesky に投稿する内容の取得が完了したので、続いては Bluesky の API を実行して投稿します。こちらは以下の記事を参考にさせていただきました。
Google Apps Script の左側のメニューから「プロジェクトの設定」を選択し、画面の下の方にある「スクリプトプロパティ」に Bluesky のユーザハンドルとアプリパスワードを設定します。
- USER_HANDLE - 投稿する Bluesky アカウントのユーザハンドル。
- APP_PASSWORD - Bluesky の設定画面から発行したアプリパスワード。
これらの情報を用いて、Bluesky に投稿します。getBlueskyAccessToken
関数で Bluesky のアクセストークンを取得し、postBluesky
で記事のタイトルを Bluesky へ投稿します。
const main = () => {
/** スプレッドシート */
const spreadsheet = SpreadsheetApp.getActive()
/** シート */
const sheet = spreadsheet.getSheetByName('sheet')
/** ブログ記事一覧 */
const posts = filterNotNotifiedPost(sheet, getRss('RSSフィードのURL'))
if (posts.length) {
/** ユーザハンドル */
const userHandle = PropertiesService.getScriptProperties().getProperty('USER_HANDLE')
/** アプリパスワード */
const appPassword = PropertiesService.getScriptProperties().getProperty('APP_PASSWORD')
/** Blueskyのアクセストークン */
const token = getBlueskyAccessToken(userHandle, appPassword)
if (token) {
posts.forEach((post) => {
try {
if (!postBluesky(userHandle, token, post.title)) {
throw Error(`${post.link}の投稿に失敗しました。`)
}
addNotifiedPostToSheet(sheet, post.link)
} catch (err) {
Logger.log(err.toString())
return null
}
})
}
}
}
/**
* Blueskyのアクセストークンを発行する
* @params identifier ユーザハンドル
* @params appPassword アプリパスワード
* @returns Blueskyのアクセストークン
*/
const getBlueskyAccessToken = (identifier, password) => {
const url = 'https://bsky.social/xrpc/com.atproto.server.createSession'
const payload = { identifier, password }
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return json.accessJwt
} catch (err) {
Logger.log(err.toString())
return null
}
}
/**
* Blueskyへ投稿する関数
* @params userHandle ユーザハンドル
* @params token Blueskyのアクセストークン
* @params text ブログ記事のタイトル
* @returns 投稿に成功したかどうか
*/
const postBluesky = (userHandle, token, title) => {
const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
const payload = {
repo: userHandle,
collection: "app.bsky.feed.post",
record: {
text: title,
createdAt: new Date().toISOString(),
}
}
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${token}`
},
contentType: 'application/json',
payload: JSON.stringify(payload),
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return true
} catch (err) {
return false
}
}
これで Bluesky へ記事のタイトルを投稿することができました。が、このままではタイトルが投稿されるだけで該当の記事へのリンクがありません。投稿するテキストに URL を含めたとしても、それは リンクにならず URL がただの文字列として投稿 されてしまいます。
Bluesky へウェブサイトカードを投稿する
Bluesky のドキュメントを見ると「Website card embeds」というものがあり、この形式で投稿するのが良さそうだということが分かります。
ウェブサイトカードの投稿で必要なのは、「記事のタイトル」「記事の URL」「記事の概要(ディスクリプション)」と、SNS などでシェアしたときに表示されるサムネイル画像(OGP 画像)の 4 つ。まずは、まだ用意ができていないサムネイル画像の取得処理を実装します。
サムネイル画像の URL はブログによって異なると思うので適宜実装していただきたいのですが、私のウェブサイトの場合は /__og-image__/static/{ページのパス}/og.png
という形式になるため、以下のような実装になります。
/**
* ブログ記事のサムネイル画像を取得する
* @param url ブログ記事のURL
* @returns サムネイル画像のBlob
*/
const getOgpImageBlob = (url = '') => {
if (url) {
const imageUrl =
`${url.replace('https://hiratake.dev/blog/', 'https://hiratake.dev/__og-image__/static/blog/')}og.png`
const res = UrlFetchApp.fetch(imageUrl)
return res.getBlob()
} else {
return null
}
}
サムネイル画像のダウンロード処理には以下の記事を参考にさせていただきました。
続いて、取得したサムネイル画像を Bluesky へ投稿します。Bluesky で画像付きの投稿を行う場合には、まず画像をアップロードして、そのレスポンスのデータを付与して投稿を行う、という流れのようです。画像のアップロードについては以下記事を参考にさせていただきました。
Bluesky のアクセストークンと画像のデータを渡して、Bluesky へ画像をアップロードする関数 postImage
を以下のように作成しました。
/**
* Blueskyへ画像を投稿する関数
* @params token Blueskyのアクセストークン
* @params blob 投稿する画像
* @returns 画像投稿のレスポンス
*/
const postImage = (token, blob) => {
const url = 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
contentType: '*/*',
payload: blob
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return json
} catch (err) {
Logger.log(err.toString())
return false
}
}
また、先ほど作成した Bluesky へ投稿を行う関数 postBluesky
を、ウェブサイトカードが投稿できるように変更します。
/**
* Blueskyへ投稿する関数
* @params userHandle ユーザハンドル
* @params token Blueskyのアクセストークン
* @params text ブログ記事のタイトル
* @params link ブログ記事のURL
* @params description ブログ記事の概要
* @params thumb サムネイル画像
* @returns 投稿に成功したかどうか
*/
const postBluesky = (userHandle, token, title, link, description, thumb) => {
const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
const payload = {
repo: userHandle,
collection: "app.bsky.feed.post",
record: {
text: title,
createdAt: new Date().toISOString(),
embed: {
'$type': "app.bsky.embed.external",
external: {
uri: link,
title,
description,
thumb: thumb.blob,
},
},
}
}
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${token}`
},
contentType: 'application/json',
payload: JSON.stringify(payload),
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return true
} catch (err) {
return false
}
}
最終的なコード
これで Bluesky へブログの記事を投稿することができるようになりました。最終的なコードは以下の通りです。
const main = () => {
/** スプレッドシート */
const spreadsheet = SpreadsheetApp.getActive()
/** シート */
const sheet = spreadsheet.getSheetByName('sheet')
/** ブログ記事一覧 */
const posts = filterNotNotifiedPost(sheet, getRss('RSSフィードのURL'))
if (posts.length) {
/** ユーザハンドル */
const userHandle = PropertiesService.getScriptProperties().getProperty('USER_HANDLE')
/** アプリパスワード */
const appPassword = PropertiesService.getScriptProperties().getProperty('APP_PASSWORD')
/** Blueskyのアクセストークン */
const token = getBlueskyAccessToken(userHandle, appPassword)
if (token) {
posts.forEach((post) => {
try {
const ogImage = getOgpImageBlob(post.link)
const thumb = postImage(token, ogImage)
if (thumb !== false) {
if (!postBluesky(userHandle, token, post.title, post.link, post.description, thumb)) {
throw Error(`${post.link}の投稿に失敗しました。`)
}
addNotifiedPostToSheet(sheet, post.link)
}
} catch (err) {
Logger.log(err.toString())
return null
}
})
}
}
}
/**
* RSSフィードを取得する
* @param url RSSフィードのURL
* @returns RSSフィードの結果の配列
*/
const getRss = (url = '') => {
if (url) {
const xml = UrlFetchApp.fetch(url).getContentText()
const document = XmlService.parse(xml)
const root = document.getRootElement()
const rss1 = root.getChildren('item', XmlService.getNamespace('http://purl.org/rss/1.0/'))
const atom = root.getChildren('entry', XmlService.getNamespace('http://www.w3.org/2005/Atom'))
if (rss1.length > 0) {
// RSS 1.0
return processingRSS10(root)
} else if (atom.length > 0) {
// Atom
return processingAtom(root)
} else {
// RSS 2.0
return processingRSS20(root)
}
} else {
return []
}
}
/**
* RSS 1.0 フィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingRSS10 = (xml) => {
const rss = XmlService.getNamespace('http://purl.org/rss/1.0/')
const dc = XmlService.getNamespace('dc', 'http://purl.org/dc/elements/1.1/')
const feedItems = xml.getChildren('item', rss)
return feedItems.map((item) => {
const title = item.getChild('title', rss).getText()
const link = item.getChild('link', rss).getText()
const description = item.getChild('description',rss).getText()
return { title, link, description }
})
}
/**
* RSS 2.0 フィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingRSS20 = (xml) => {
const feedItems = xml.getChild('channel').getChildren('item')
return feedItems.map((item) => {
const title = item.getChild('title').getText()
const link = item.getChild('link').getText()
const description = item.getChild('description').getText()
return { title, link, description }
})
}
/**
* Atomフィードを処理する関数
* @param xml フィードデータ
* @returns コンテンツのタイトルとURLの配列
*/
const processingAtom = (xml) => {
const atom = XmlService.getNamespace('http://www.w3.org/2005/Atom')
const feedItems = xml.getChildren('entry', atom)
return feedItems.map((item) => {
const title = item.getChild('title', atom).getText()
const link = item
.getChild('link', atom)
.getAttribute('href')
.getValue()
const description = item.getChild('summary', atom).getText()
return { title, link, description }
})
}
/**
* 投稿済みのブログ記事を除外する
* @params sheet スプレッドシートのオブジェクト
* @params items ブログ記事の配列
* @returns 投稿済みのブログ記事を除外したブログ記事の配列
*/
const filterNotNotifiedPost = (sheet, items) => {
if (sheet.getLastRow()) {
const notified = sheet.getRange(1, 1, sheet.getLastRow(), 1).getValues().flat()
return items.filter((item) => !notified.includes(item.link))
} else {
return items
}
}
/**
* 投稿済みのブログ記事をスプレッドシートへ追加する
* @params sheet スプレッドシートのオブジェクト
* @params url 追加するブログ記事のURL
*/
const addNotifiedPostToSheet = (sheet, url) => {
sheet.insertRowBefore(1)
sheet.getRange(1, 1).setValue(url)
}
/**
* ブログ記事のサムネイル画像を取得する
* @param url ブログ記事のURL
* @returns サムネイル画像のBlob
*/
const getOgpImageBlob = (url = '') => {
if (url) {
// const imageUrl =
// `${url.replace('https://hiratake.dev/blog/', 'https://hiratake.dev/__og-image__/static/blog/')}og.png`
const res = UrlFetchApp.fetch(imageUrl)
return res.getBlob()
} else {
return null
}
}
/**
* Blueskyのアクセストークンを発行する
* @params identifier ユーザハンドル
* @params appPassword アプリパスワード
* @returns Blueskyのアクセストークン
*/
const getBlueskyAccessToken = (identifier, password) => {
const url = 'https://bsky.social/xrpc/com.atproto.server.createSession'
const payload = { identifier, password }
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return json.accessJwt
} catch (err) {
Logger.log(err.toString())
return null
}
}
/**
* Blueskyへ画像を投稿する関数
* @params token Blueskyのアクセストークン
* @params blob 投稿する画像
* @returns 画像投稿のレスポンス
*/
const postImage = (token, blob) => {
const url = 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
contentType: '*/*',
payload: blob
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return json
} catch (err) {
Logger.log(err.toString())
return false
}
}
/**
* Blueskyへ投稿する関数
* @params userHandle ユーザハンドル
* @params token Blueskyのアクセストークン
* @params text ブログ記事のタイトル
* @params link ブログ記事のURL
* @params description ブログ記事の概要
* @params thumb サムネイル画像
* @returns 投稿に成功したかどうか
*/
const postBluesky = (userHandle, token, title, link, description, thumb) => {
const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
const payload = {
repo: userHandle,
collection: "app.bsky.feed.post",
record: {
text: title,
createdAt: new Date().toISOString(),
embed: {
'$type': "app.bsky.embed.external",
external: {
uri: link,
title,
description,
thumb: thumb.blob,
},
},
}
}
const options = {
method: 'post',
headers: {
'Authorization': `Bearer ${token}`
},
contentType: 'application/json',
payload: JSON.stringify(payload),
}
try {
const res = UrlFetchApp.fetch(url, options)
const json = JSON.parse(res.getContentText())
return true
} catch (err) {
return false
}
}
定期的に実行する
最後に、記述した Google Apps Script を自動で実行されるように設定します。
左側のメニューから「トリガー」を選択し、画面右下にある「トリガーを追加」から新しいトリガーを作成します。実行する関数には「main
」、イベントのソースは「時間主導型」、あとは関数を実行する頻度を選択します。
リンクを投稿するときはウェブサイトカードなる形式で投稿しないといけないとか、投稿とは別で画像をアップロードしないといけないとかのあたりが結構苦戦しました。説明を記事として書いてくださっているエンジニアの方々に感謝🙏
Bluesky 自体がまだまだ発展途上ということで、仕様変更などで動かなくなるなどあるかもですが、ひとまず動くものを作ることができて良かったです。