/**
* 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 }
})
}
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
}
}
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)
}
- USER_HANDLE - 投稿する Bluesky アカウントのユーザハンドル。
- APP_PASSWORD - 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
}
}
/**
* ブログ記事のサムネイル画像を取得する
* @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 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
}
}
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
}
}