Hiratake Web ロゴ

GASでRSSを取得してブログの更新をBlueskyに投稿する

投稿した日
更新した日
書いたひと
icon
ひらたけ

スプレッドシートをつくる

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 }
  })
}

投稿済みの記事を除外する

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)
}

Bluesky にテキストを投稿する

  • 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
  }
}

Bluesky へウェブサイトカードを投稿する

/**
 * ブログ記事のサムネイル画像を取得する
 * @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
  }
}

定期的に実行する