🦋

GASでBlueskyAPIのハッシュタグやリンクや画像の投稿

2024/11/07に公開

はじめに

TwitterのAPI制限が厳しくなってしまったので、BotアカウントをBlueskyに移す方も多いのではないでしょうか?BlueskyのAPIはとてもシンプルで使いやすいですが、ハッシュタグやリンクを含む投稿の場合はちょっと癖があります。それをGASでやりたい話。

事前に

「設定」→「アプリパスワード」からアプリパスワードを発行しておいてください。APIを叩く際に使います。

セッションの作成

各種APIを叩くために必要な認証情報を取得するため、createSessionのAPIでセッションを作成してaccessJwtを取得します。

function getAccessJwt() {
  // ログイン情報
  const data = {
    identifier: 'xxxx.bsky.social', // アカウントのハンドル名
    password: 'XXXX', // アプリパスワード
  }
  // セッション作成
  const url = 'https://bsky.social/xrpc/com.atproto.server.createSession'
  const options = {
    method: 'POST',
    contentType: 'application/json',
    payload: JSON.stringify(data),
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())
  // JWTを返却
  return result.accessJwt
}

投稿API

まずは基本の投稿APIです。createPostというAPIを叩きます。repoパラメータにはdidを設定しろという記事もありますが、ハンドル名でもいけます。
https://docs.bsky.app/docs/get-started#create-a-post
実行後に返却されるuricidは返信をする際に必要になるので、もし必要であれば取っておきましょう。

function createPost(text) {
  // 認証情報
  const handle = 'xxxx.bsky.social' // アカウントのハンドル名
  const jwt = getAccessJwt()
  // レコード情報
  const record = {
    text,
    createdAt: (new Date()).toISOString(),
  }
  // 投稿情報
  const data = {
    repo: handle,  // didかハンドル名を指定
    collection: 'app.bsky.feed.post',
    record,
  }
  // 投稿
  const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
  const options = {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` },
    contentType: 'application/json',
    payload: JSON.stringify(data),
  }
  const response = UrlFetchApp.fetch(url, options)
  // uriとcidを返却
  return JSON.parse(response.getContentText())
}

ハッシュタグ投稿

TwitterのAPIでは#をつけて投稿すれば勝手にハッシュタグになりましたが、Blueskyではそのままだとただのテキスト投稿になってしまいます。そこで投稿時にfacetを追加してあげることでハッシュタグとして認識されます。これはリンクやメンションも同じ挙動になります。

https://docs.bsky.app/docs/advanced-guides/post-richtext

facetではハッシュタグ化する文字の始まりと終わりをバイト数で指定する必要があり、文字数ではなくバイト数指定なのでこれがめんどいポイントです。ただし#をつけなくてもハッシュタグ化できるのは自由度は高いです。
今回は投稿テキストから#で始まる文字をハッシュタグとしてfacetを生成するメソッドを実装します。

function getHashTagFacets(text) {
  const facets = []
  // ハッシュタグ検索
  const tags = text.match(/#\S+/g)
  if (tags) {
    for (const tag of tags) {
      const tagText = tag.replace(/^#/, '')
      // タグの始まりのバイト数
      const byteStart = getByteSize(text.substring(0, text.indexOf(tag)))
      // タグの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize(tag)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#tag',
          tag: tagText,
        }],
      }
      facets.push(facet)
    }
  }
  return facets
}

// バイト数取得
function getByteSize(text) {
  return encodeURI(text).split(/%..|./).length - 1
}

リンク投稿

BlueskyでURLを投稿すると自動的に省略された形で投稿されます。ただしAPIで投稿する場合は、省略してくれないので人力で整える必要があります。
省略の仕様的にはドメイン + 13文字 + ...という形式で省略されるっぽいので、今回はその仕様に合わせます。

function getLinkFacets(text) {
  const facets = []
  // URL検索
  const urls = text.match(/http\S+/g)
  if (urls) {
    for (const url of urls) {
      // ドメイン部分を取得
      const domain = url.replace(/^https?:\/\/([^/]+).*$/, '$1')
      // ドメイン + 13文字
      let urlText = url.substring(url.indexOf(domain), url.indexOf(domain) + domain.length + 13)
      // パスが13文字を超える場合は'...'を付ける
      if (url.substring(url.indexOf(domain) + domain.length).length > 13) {
        urlText += '...'
      }
      // URLの始まりのバイト数
      const byteStart = getByteSize(text.substring(0, text.indexOf(url)))
      // URLの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize(urlText)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#link',
          uri: url,
        }],
      }
      facets.push(facet)
      // URLを省略形式に変換
      text = text.replace(url, urlText)
    }
  }
  // textも返す
  return { text, facets }
}

メンション投稿

メンションの場合は、facetにdidというユーザーの識別子を指定する必要があるので、getProfilesのAPIを使ってメンションするユーザーのdidを取得します。getProfilesはパブリックなAPIなので認証情報は不要です。下記のURLにアクセスすればブラウザ上でも情報が確認できます。
https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?actors[]=gyumesy-bot.bsky.social

function getMentionFacets(text) {
  const facets = []
  // メンション検索
  const mentions = text.match(/@\S+/g)
  if (mentions) {
    // ユーザー情報取得
    const handles = mentions.map((mention) => mention.replace(/^@/, ''))
    const profiles = getUserProfiles(handles)
    for (const mention of mentions) {
      const handle = mention.replace(/^@/, '')
      // ユーザー情報が取れなければ無視
      const profile = profiles.find((profile) => profile.handle === handle)
      if (!profile) continue
      // メンションの始まりのバイト数
      const byteStart = getByteSize(text.substring(0, text.indexOf(mention)))
      // メンションの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize(mention)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#mention',
          did: profile.did,
        }],
      }
      facets.push(facet)
    }
  }
  return facets
}

// ユーザー情報取得
function getUserProfiles(handles) {
  // クエリパラメータ
  const query = handles.map((handle) => `actors[]=${handle}`).join('&')
  // ユーザー情報取得
  const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${query}`
  const options = {
    method: 'GET',
    contentType: 'application/json',
    muteHttpExceptions: false,
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())
  // ユーザー情報の配列を返却
  return result.profiles
}

画像投稿

画像投稿はTwitterとフローが似ていて、先に画像をアップロードして返却されたBlob情報を投稿に付け足す流れになります。

function uploadImage(imageUrl) {
  // JWT取得
  const jwt = getAccessJwt()

  // 画像URLからBlobを生成
  const blob = UrlFetchApp.fetch(imageUrl).getBlob()

  // 画像アップロード
  const url = 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
  const options = {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` },
    payload: blob,
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())

  // blobパラメータを返却
  return result.blob
}

画像や動画、リンクカードなどの埋め込み情報は、投稿時にembedパラメータにセットします。ALTテキストは必須なので必ず設定してください。

  // 画像情報を追加
  const blob = uploadImage(imageUrl)
  record.embed = {
    '$type': 'app.bsky.embed.images',
    images: [{
      image: blob,
      alt: 'ALTテキスト',
    }],
  }

全体のソースコード

最後に全体のソースを貼っておきます。facet生成は1つのメソッドにまとめてます。

// 認証情報はScripte Propertyに入れておくとヨシ!
const APP_HANDLE = 'xxxx.bsky.social' // アカウントのハンドル名
const APP_PASSWORD = 'xxxxx' // アプリパスワード

/**
 * Bluesky投稿
 * @param {string} text
 * @param {{ imageUrl:string, altText: string }[]} [imageInfos=[]]
 * @return {{ uid: string, cid: string }}
 */
function post(text, imageInfos = []) {
  // 認証情報取得
  const jwt = getAccessJwt_()
  // ハッシュタグやリンク情報を生成
  const { text: postText, facets } = createFacets_(text)
  // レコード情報を作成
  let record = {
    text: postText,
    createdAt: (new Date()).toISOString(),
  }
  // facet情報追加
  if (facets.length > 0) record.facets = facets
  // 画像アップロード
  const images = []
  for (const imageInfo of imageInfos) {
    const blob = uploadImage_(jwt, imageInfo.imageUrl)
    images.push({
      image: blob,
      alt: imageInfo.altText,
    })
  }
  // 画像情報追加
  if (images.length > 0) {
    record.embed = {
      '$type': 'app.bsky.embed.images',
      images,
    }
  }
  // 投稿情報
  const data = {
    repo: APP_HANDLE, // ハンドル名を指定
    collection: 'app.bsky.feed.post',
    record,
  }
  // 投稿
  const url = 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
  const options = {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` },
    contentType: 'application/json',
    payload: JSON.stringify(data),
  }
  const response = UrlFetchApp.fetch(url, options)
  return JSON.parse(response.getContentText())
}

/**
 * 認証JWT取得
 * @return {string}
 */
function getAccessJwt_() {
  // ログイン情報
  const data = {
    identifier: APP_HANDLE, // アカウントのハンドル名
    password: APP_PASSWORD, // アプリパスワード
  }
  // セッション作成
  const url = 'https://bsky.social/xrpc/com.atproto.server.createSession'
  const options = {
    method: 'POST',
    contentType: 'application/json',
    payload: JSON.stringify(data),
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())
  // JWTを返却
  return result.accessJwt
}

/**
 * facet情報の生成
 * @param {string} text
 * @return {{ text: string, facets: Object[] }
 */
function createFacets_(text) {
  // facet情報
  const facets = []

  // ハッシュタグ検索
  const tags = text.match(/#\S+/g)
  if (tags) {
    for (const tag of tags) {
      const tagText = tag.replace(/^#/, '')
      // タグの始まりのバイト数
      const byteStart = getByteSize_(text.substring(0, text.indexOf(tag)))
      // タグの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize_(tag)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#tag',
          tag: tagText,
        }],
      }
      facets.push(facet)
    }
  }

  // URL検索
  const urls = text.match(/http\S+/g)
  if (urls) {
    for (const url of urls) {
      // ドメイン部分を取得
      const domain = url.replace(/^https?:\/\/([^/]+).*$/, '$1')
      // ドメイン + 13文字
      let urlText = url.substring(url.indexOf(domain), url.indexOf(domain) + domain.length + 13)
      // パスが13文字を超える場合は'...'を付ける
      if (url.substring(url.indexOf(domain) + domain.length).length > 13) {
        urlText += '...'
      }
      // URLの始まりのバイト数
      const byteStart = getByteSize_(text.substring(0, text.indexOf(url)))
      // URLの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize_(urlText)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#link',
          uri: url,
        }],
      }
      facets.push(facet)
      // URLを省略形式に変換
      text = text.replace(url, urlText)
    }
  }

  // メンション検索
  const mentions = text.match(/@\S+/g)
  if (mentions) {
    // ユーザー情報取得
    const handles = mentions.map((mention) => mention.replace(/^@/, ''))
    const profiles = getUserProfiles_(handles)
    for (const mention of mentions) {
      const handle = mention.replace(/^@/, '')
      // ユーザー情報が取れなければ無視
      const profile = profiles.find((profile) => profile.handle === handle)
      if (!profile) continue
      // メンションの始まりのバイト数
      const byteStart = getByteSize_(text.substring(0, text.indexOf(mention)))
      // メンションの終わりのバイト数+1
      const byteEnd = byteStart + getByteSize_(mention)
      // facet生成
      const facet = {
        index: {
          byteStart,
          byteEnd,
        },
        features: [{
          '$type': 'app.bsky.richtext.facet#mention',
          did: profile.did,
        }],
      }
      facets.push(facet)
    }
  }

  // textとfacet情報を返却
  return { text, facets }
}

/**
 * バイトサイズ取得
 * @param {string} text
 * @return {number}
 */
function getByteSize_(text) {
  return encodeURI(text).split(/%..|./).length - 1
}

/**
 * ユーザー情報取得
 * @param {string[]} handles
 * @return {Object[]}
 */
function getUserProfiles_(handles) {
  // クエリパラメータ
  const query = handles.map((handle) => `actors[]=${handle}`).join('&')
  // ユーザー情報取得
  const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles?${query}`
  const options = {
    method: 'GET',
    contentType: 'application/json',
    muteHttpExceptions: false,
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())
  // ユーザー情報の配列を返却
  return result.profiles
}

/**
 * 画像アップロード
 * @param {string} jwt
 * @param {string} imageUrl
 * @return {Object}
 */
function uploadImage_(jwt, imageUrl) {
  // 画像URLからBlobを生成
  const blob = UrlFetchApp.fetch(imageUrl).getBlob()

  // 画像アップロード
  const url = 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
  const options = {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${jwt}` },
    payload: blob,
  }
  const response = UrlFetchApp.fetch(url, options)
  const result = JSON.parse(response.getContentText())

  // blobパラメータを返却
  return result.blob
}

実際に使ってみた

function testPost() {
  // ハッシュタグとリンクとメンションと画像を含む投稿
  const text = `@gyumesy.bsky.social
Hello Matsuya!
https://www.matsuyafoods.co.jp/matsuya/menu/gyumeshi/index.html

#松屋`

  const imageInfos = [{
    imageUrl: 'https://www.matsuyafoods.co.jp/menu/upload_images/gyu_hp_s.jpg',
    altText: '牛めし',
  }]

  post(text, imageInfos)
}

↓結果

おわりに

GAS最高!

Discussion