【GAS】LISTENやStandFMの更新をBlueskyに自動投稿!コード&設定方法を紹介

当ページのリンクにはプロモーションが含まれています。

こんにちは!ポッドキャスト配信サービス「LISTEN(リッスン)」や「StandFM(スタンドエフエム)」のユーザー、まなてぃです。

LISTENやStandFMで新しいエピソードを公開するたび、Blueskyにも手動でお知らせを投稿していませんか?

毎回コピペするのは正直ちょっと面倒…

うっかり投稿するのを忘れちゃう…

Google Apps Script (GAS) を使えば、LISTENやStandFMの更新情報をBlueskyへ自動投稿する仕組みが作れます!(努力すれば無料ですw)

今回、私のようなプログラミング経験があまりない主婦でも、ChatGPT先生の力を借りながら実現できました。

この記事では、LISTENやStandFMのRSSフィードとGASを使って、Blueskyに新しいエピソード情報を自動で投稿する方法を、コードと設定手順の写真を交えながら、紹介します!

この記事を読めば、あなたもLISTENやStandFMの更新情報を手間なくBlueskyに届けられるようになり、面倒な手作業から解放されるのではないでしょうか。ぜひ最後まで読んで、自動投稿システム構築にチャレンジしてみてください!

  • コードの利用は自己責任でお願いいたします。本コードの利用によるトラブル等の責任は負いかねます。
  • このコードは素人ノンプログラマーがchatGPTの協力を得て作成したものです。完璧なものではありません
    動けばヨシ!の精神で作成しております。
  • コードはアレンジしてご活用いただいて問題ありません
  • 本コードを販売することはご遠慮ください。
  • コードの詳しい解説については、chatGPT先生に聞いた方が正確な回答が得られると思います。コードのアレンジ等もchatGPTなどに相談してご活用ください。
参考にさせていただいた素晴らしいブログ記事

大変参考になりました。ありがとうございました!

(スポンサーリンク)

目次

なぜGoogle Apps Script (GAS) で自動化するの?

「そもそも、なんで自動投稿する必要があるの?」と感じる方もいるかもしれませんね。

もちろん、手動でも投稿はできますが、自動化にはこんなメリットがあります!

  • 手間と時間を大幅に削減できる!:毎回手動で投稿する手間がなくなる
  • 投稿忘れを防げる!:うっかり投稿を忘れてしまうことがなくなる
  • Blueskyのアカウント活用:コンスタントな情報発信ができ、アカウントを活性化できる

IFTTTとかZapierじゃダメなの?

私も最初は、IFTTT(イフト)やZapier(ザピアー)といった有名な自動化サービスを検討しました。これらは設定が比較的簡単な反面、

  • Blueskyとの連携が公式でサポートされていなかったり、情報が少なかったりする(2025年5月現在)
  • 無料でできる範囲に制限がある(例:Xの連携は有料プランのみ、実行回数制限など)

といった点が気になりました。お金をかけずに、なるべく無料で実現したいという希望がありました。

そこで、Google Apps Script (GAS) を使おうと思いました!

GASなら、以下のようなメリットがあると考えました。

  • 無料で利用できる(GoogleアカウントさえあればOK!)
  • Googleスプレッドシートと連携して、投稿履歴の管理なども柔軟にできる
  • JavaScriptベースなので、プログラミングの知識が少しあり、ChatGPT先生に頼れば、かなり自由にカスタマイズできる

実際に、こちらの素晴らしい記事を参考にさせていただき、chatGPT先生と会話しながら、私でも実装することができました!

「ちょっと難しそう…」と感じるかもしれませんが、この記事で手順を追って解説してみたいと思います。

準備するもの

自動投稿システムを構築する前に、以下のものを準備しておきましょう。

  • LISTENやStandFMのアカウントと、ご自身の番組のRSSフィードURL
  • Blueskyのアカウント
    • Blueskyのユーザーハンドル(例:yourname.bsky.social
    • Blueskyのアプリパスワード
      (これは通常のログインパスワードとは異なります。Blueskyの設定画面から発行が必要です)
  • Googleアカウント (GoogleスプレッドシートとGoogle Apps Scriptを利用するため)

LISTENのRSSフィード取得方法

  • LISTENにアクセスし、プロフィールアイコンをクリック
  • ダッシュボード ボタンをクリック
  • 配信サービス RSSのアイコンをクリック
  • RSSのURLをコピー
    • コード内に使いますのでメモしておいてください
Image from Gyazo

StandFMのRSSフィード取得方法

  • StandFMのスマートフォンアプリから操作します
  • マイページ > 設定 > ポッドキャスト設定をタップすると取得できます(参考: https://help.stand.fm/podcast
  • 取得したRSSフィードをPCに共有します
    • コード内に使いますのでメモしておいてください
StandFMのRSSフィード取得方法
1
StandFMのRSSフィード取得方法
2
StandFMのRSSフィード取得方法
3
StandFMのRSSフィード取得方法
4

Blueskyのアプリパスワード取得について

Blueskyにログインし「設定」を押す

Blueskyのアプリパスワード取得について

「プライバシーとセキュリティ」を押す

Blueskyのアプリパスワード取得について

「アプリパスワード」ボタンを押す

Blueskyのアプリパスワード取得について

「アプリパスワードを追加」ボタンを押す

Blueskyのアプリパスワード取得について

アプリパスワードに名前をつけ、「次へ」ボタンを押す
(なんのアプリのときに生成したパスワードかわかるような名前を付けておくといいかもです)

Blueskyのアプリパスワード取得について

アプリパスワードが表示されるので、なくさない場所にメモしておく

※ご注意
このパスワードはこの画面の1回しか表示されません!
メモする前に閉じてしまった場合は「アプリパスワードを追加」ボタンを押す手順から新規作成しましょう

Blueskyのアプリパスワード取得について

GASでLISTENやStandFMの更新をBlueskyに自動投稿しよう

ここからは、実際にGoogle Apps Scriptを使って自動投稿システムを構築していく手順を、写真を交えながら詳しく解説します。

ステップ1:Googleスプレッドシートの準備

まずは、投稿したエピソードの情報を記録・管理するためのGoogleスプレッドシートを作成します。

Googleスプレッドシートの準備
Googleスプレッドシートの準備

ステップ2:Google Apps Scriptの設定とコードの貼り付け

次に、Googleスプレッドシートに紐づいたGoogle Apps Scriptを設定し、自動投稿のためのコードを記述(コピペ)していきます。

1. スクリプトエディタを開く

スプレッドシートのメニューバーから「拡張機能」>「Apps Script」を選択します

Google Apps Scriptの設定とコードの貼り付け

こんな感じの画面が開きます。

2. コードを貼り付ける

以下のコードをスクリプトエディタに貼り付けます。

コードを見る(長いよ)
クリックしたら開きます
/************** 設定値 *****************/
const FEED_URL  = 'ここにRSSフィードのURLをいれる';  // 取得したい RSSフィード
const SHEET_NAME = 'Feed';                     // 書き込み先シート名

/**
 * Blueskyのトークン保管(Script Properties)
 * Apps Script エディタ → プロジェクト設定 → スクリプト プロパティ で保存
 *    BSKY_USER:  your-handle.bsky.social
 *    BSKY_APP_PWD: xxxx-xxxx-xxxx-xxxx
 */
/****************************************/


/**
 * ========== 初回セットアップ専用 ==========
 * RSS フィードを読み取り、スプレッドシートへ保存するだけ。
 * Bluesky には投稿しない。
 *
 * 使い方: 手動で 1 回実行 → シートを確認 → 必要なら Posted を手動で FALSE に変更してください
 * TRUE: 投稿済として扱う
 * FALSE: 未投稿として扱う→運用用のプログラムを動かしたときにBlueskyに投稿される対象
 */
function initFetchFeed() {
  const items = getRss(FEED_URL);
  if (items.length === 0) { Logger.log('取得結果 0 件'); return; }

  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);

  // ヘッダー行を用意
  if (sheet.getLastRow() === 0) {
    sheet.appendRow(['Title','Link','Description','FetchedAt',
                     'Posted','PostedAt','BlueskyURL']);
  }

  /* ===== 既存リンクを取得(データ行がある時だけ) ===== */
  let existing = new Set();
  if (sheet.getLastRow() > 1) {                   // 行データが存在する場合のみ
    existing = new Set(
      sheet.getRange(2, 2, sheet.getLastRow() - 1, 1)
           .getValues()
           .flat()
    );
  }

  /* ===== 新規行を作成 ===== */
  const newRows = items
    .filter(it => !existing.has(it.link))
    .map(({title, link, description}) => [
      title, link, description, new Date(),
      true, '', ''    // Posted / PostedAt / BlueskyURL
    ]);

  if (newRows.length === 0) { Logger.log('新規 0 行'); return; }

  sheet.getRange(sheet.getLastRow() + 1, 1, newRows.length, 7)
       .setValues(newRows);

  Logger.log(`初回取得: ${newRows.length} 行を追加(投稿は行いません)`);
}



/**
 * ========== 運用用 ==========
 * (A) 新着フィードをシートへ追加
 * (B) Posted = FALSE の行を Bluesky へ投稿
 *  ↳ 投稿成功した行は Posted=TRUE, PostedAt, BlueskyURL を更新
 *
 * これを時間駆動トリガ(例: 15 分ごと)に設定しておく。
 */
function updateFeedAndPost() {
  /* --------- (A) 新着を取得してシートへ追加 --------- */
  const items = getRss(FEED_URL);
  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);

  if (sheet.getLastRow() === 0) {
    sheet.appendRow(['Title','Link','Description','FetchedAt',
                     'Posted','PostedAt','BlueskyURL']);
  }

  const existing = new Set(
    sheet.getRange(2, 2, Math.max(0, sheet.getLastRow()-1), 1)
         .getValues()
         .flat()
  );
  const freshRows = items
    .filter(it => !existing.has(it.link))
    .map(({title, link, description}) => [
      title, link, description, new Date(),
      false, '', ''
    ]);

  if (freshRows.length) {
    sheet.getRange(sheet.getLastRow()+1, 1, freshRows.length, 7)
         .setValues(freshRows);
    Logger.log(`新規追加: ${freshRows.length} 行`);
  } else {
    Logger.log('新規記事なし');
  }

  /* --------- (B) 未投稿行を Bluesky へ投稿 --------- */
  postUnsentToBluesky();   // ここは既に作成済みの関数を呼ぶだけ
}




/*================================================================
  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 {XmlService.Element} root
 * @return {{title:string,link:string,description:string}[]}
 */
function processingRSS10(root) {
  const RSS = XmlService.getNamespace('http://purl.org/rss/1.0/');
  const items = root.getChildren('item', RSS);

  return items.map(item => ({
    title:       item.getChildText('title', RSS),
    link:        item.getChildText('link',  RSS),
    description: item.getChildText('description', RSS) || ''
  }));
}

/**
 * RSS 2.0 を処理
 * @param {XmlService.Element} root
 * @return {{title:string,link:string,description:string}[]}
 */
function processingRSS20(root) {
  const items = root.getChild('channel').getChildren('item');

  return items.map(item => ({
    title:       item.getChildText('title')       || '',
    link:        item.getChildText('link')        || '',
    // description がない場合は content:encoded を fallback
    description: item.getChildText('description') ||
                 (item.getChildText('content:encoded') || '')
  }));
}

/**
 * Atom フィードを処理
 * @param {XmlService.Element} root
 * @return {{title:string,link:string,description:string}[]}
 */
function processingAtom(root) {
  const ATOM  = XmlService.getNamespace('http://www.w3.org/2005/Atom');
  const items = root.getChildren('entry', ATOM);

  return items.map(item => ({
    title:       item.getChildText('title', ATOM) || '',
    link:        item.getChild('link', ATOM)
                    ?.getAttribute('href')
                    ?.getValue() || '',
    description: item.getChildText('summary', ATOM) ||
                 item.getChildText('content', ATOM)  || ''
  }));
}


/**
 * Bluesky へ未投稿行を送信し、シートを更新する
 * シートに残っている Posted=FALSE の行だけ Bluesky へ投稿し
 * 成功したら Posted / PostedAt / BlueskyURL を更新する
 * 失敗時したら Posted は FALSE のまま残るので再実行でリトライ可
 * 本文は 300 文字制限を自動トリム
 * 外部リンクカードにサムネイル画像を付与(og:image → blob アップロード)
 */
function postUnsentToBluesky() {
  const ss    = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(SHEET_NAME);
  if (!sheet) return;

  /* ===== 認証 ===== */
  const USER = PropertiesService.getScriptProperties().getProperty('BSKY_USER');
  const PWD  = PropertiesService.getScriptProperties().getProperty('BSKY_APP_PWD');
  if (!USER || !PWD) { Logger.log('USER/PWD 未設定'); return; }

  let accessJwt, did, handle;
  try {
    const loginRes = UrlFetchApp.fetch(
      'https://bsky.social/xrpc/com.atproto.server.createSession',
      { method:'post', contentType:'application/json', muteHttpExceptions:true,
        payload: JSON.stringify({ identifier: USER, password: PWD }) }
    );
    if (loginRes.getResponseCode() !== 200) {
      Logger.log(`[LOGIN] ${loginRes.getResponseCode()} : ${loginRes.getContentText()}`);
      return;
    }
    ({ accessJwt, did, handle } = JSON.parse(loginRes.getContentText()));
  } catch (e) { Logger.log(`LOGIN 例外: ${e}`); return; }

  /* ===== シート読み込み ===== */
  const data = sheet.getDataRange().getValues(); data.shift();      // ヘッダー
  const col  = { title:0, link:1, desc:2, posted:4, postedAt:5, url:6 };
  const bearer = { Authorization:`Bearer ${accessJwt}` };

  const tagsArr = ['#声日記', '#LISTEN', '#standfm', '#音声配信'];   // ←←←←←←←←←←←←←←←←←←← 好きなタグに変更できます
  const tagsStr = tagsArr.join(' ');
  const tagLen  = tagsStr.length + 2;                               // "\n\n" + tags

  data.forEach((row, i) => {
    if (row[col.posted] === true) return;          // 投稿済みスキップ

    const url   = row[col.link];

    /* ---------- 1) 本文(300 文字制限) ---------- */
    const title = trimTitle(row[col.title], 300 - url.length - tagLen - 1);
    const text  = `${title}\n${url}\n\n${tagsStr}`;

    /* ---------- 2) facet 生成 ---------- */
    const facets = buildFacets(text, url, tagsArr);

    /* ---------- 3) サムネイル取得 & blob アップロード ---------- */
    let thumbBlob;
    try {
      const thumbUrl = extractOgImage(url);
      if (thumbUrl) {
        const imgResp = UrlFetchApp.fetch(thumbUrl, { muteHttpExceptions:true });
        const blob    = imgResp.getBlob();
        if (blob.getBytes().length <= 950000) {                 // 950KB 以下だけアップ
          const uploadRes = UrlFetchApp.fetch(
            'https://bsky.social/xrpc/com.atproto.repo.uploadBlob',
            { method:'post', headers: bearer, payload: blob }
          );
          if (uploadRes.getResponseCode() === 200) {
            thumbBlob = JSON.parse(uploadRes.getContentText()).blob;
          }
        }
      }
    } catch(e) { Logger.log(`[THUMB] row=${i+2} : ${e}`); }

    /* ---------- 4) レコード ---------- */
    const record = {
      $type : 'app.bsky.feed.post',
      text,
      facets,
      createdAt : new Date().toISOString(),
      embed : {
        $type : 'app.bsky.embed.external',
        external : {
          uri        : url,
          title      : row[col.title],
          description: row[col.desc] || '',
          ...(thumbBlob ? { thumb: thumbBlob } : {})
        }
      }
    };

    /* ---------- 5) 投稿 ---------- */
    try {
      const resp = UrlFetchApp.fetch(
        'https://bsky.social/xrpc/com.atproto.repo.createRecord',
        { method:'post', contentType:'application/json', headers: bearer,
          muteHttpExceptions:true,
          payload: JSON.stringify({ repo:did, collection:'app.bsky.feed.post', record }) }
      );
      Logger.log(`[POST] row=${i+2} status=${resp.getResponseCode()}`);
      if (resp.getResponseCode() !== 200) return;

      const { uri } = JSON.parse(resp.getContentText());
      const webUrl  = atUriToWebUrl(uri, handle);
      const r       = i + 2;
      sheet.getRange(r, col.posted+1 ).setValue(true);
      sheet.getRange(r, col.postedAt+1).setValue(new Date());
      sheet.getRange(r, col.url+1    ).setValue(webUrl);
      Utilities.sleep(1500);
    } catch(e) {
      Logger.log(`[POST 例外] row=${i+2} : ${e}`);
    }
  });
}

/* =======================================================
   URL / タグを facet 化するユーティリティ
======================================================= */
function buildFacets(text, link, tags) {
  const utf8Len = s => Utilities.newBlob(s).getBytes().length;
  const facets = [];

  /* --- URL facet --- */
  const linkPos = text.indexOf(link);
  if (linkPos !== -1) {
    facets.push({
      index: {
        byteStart: utf8Len(text.slice(0, linkPos)),
        byteEnd  : utf8Len(text.slice(0, linkPos + link.length))
      },
      features: [{ $type: 'app.bsky.richtext.facet#link', uri: link }]
    });
  }

  /* --- Tag facets --- */
  tags.forEach(tag => {
    const pos = text.indexOf(tag);
    if (pos === -1) return;
    facets.push({
      index: {
        byteStart: utf8Len(text.slice(0, pos)),
        byteEnd  : utf8Len(text.slice(0, pos + tag.length))
      },
      features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag.slice(1) }]
    });
  });

  return facets;
}

/* 文字数トリム */
function trimTitle(title, maxLen) {
  if (title.length <= maxLen) return title;
  return title.slice(0, maxLen - 1) + '…';
}

/* og:image 抽出(最初の 1 枚)*/
function extractOgImage(pageUrl) {
  try {
    const html = UrlFetchApp.fetch(pageUrl, { muteHttpExceptions:true, followRedirects:true })
                  .getContentText();
    const m = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
    return m ? m[1] : null;
  } catch(e) { return null; }
}


/**
 * at:// 形式 URI → https://bsky.app/… に変換
 * @param {string} atUri 例: at://did:plc:xxx/app.bsky.feed.post/3ke…
 * @param {string} handle ユーザーの @handle
 */
function atUriToWebUrl(atUri, handle) {
  // rkey は URI の最後のスラッシュ以降
  const rkey = atUri.split('/').pop();
  return `https://bsky.app/profile/${handle}/post/${rkey}`;
}




//////////////////////////////
// デバック用 //
//////////////////////////////

/**
 * もしBlueskyのログインにうまくいかない場合、以下のコードでログイン確認をする
 * 200 が返れば資格情報は正しい
 * 成功例: [LOGIN] status=200 body={"accessJwt":"eyJhbGciOi...","did":"did:plc:xxx", ...}

 * 401 が返る → handle / app-password のどちらかが不正
*/
function testBlueskyLogin() {
  const USER = PropertiesService.getScriptProperties().getProperty('BSKY_USER')?.trim();
  const PWD  = PropertiesService.getScriptProperties().getProperty('BSKY_APP_PWD')?.trim();

// ログインIDとAPPパスワードの確認
Logger.log(`USER='${USER}' PWD='${PWD}' len=${PWD.length}`);
  const res = UrlFetchApp.fetch(
    'https://bsky.social/xrpc/com.atproto.server.createSession',
    {
      method: 'post',
      contentType: 'application/json',
      muteHttpExceptions: true,
      payload: JSON.stringify({ identifier: USER, password: PWD })
    }
  );
  Logger.log('status=%s\nbody=%s', res.getResponseCode(), res.getContentText());
}

/** 
 * BlueskyのログインIDに、目に見えない文字が混入していないか確認するコード
 * 見えない特殊文字 (U+202C / RTL Mark など) 
 * 6d 61 … 6c だけなら ASCII なので問題なし
 * 末尾に 202c や 200f などが出たら 制御文字 が混入している
*/
function debugUserString() {
  const raw = PropertiesService.getScriptProperties().getProperty('BSKY_USER') || '';
  Logger.log('len=%s chars=%s', raw.length,
    [...raw].map(c => c.charCodeAt(0).toString(16)).join(' '));
}


Image from Gyazo

const FEED_URL = ‘ここにRSSフィードのURLをいれる‘; // 取得したい RSSフィード

の部分に、取得したいRSSフィードのURLを入力します。

Google Apps Scriptの設定とコードの貼り付け
こんなかんじ

ここまでできたら、ドライブに一度保存しましょう。

Google Apps Scriptの設定とコードの貼り付け

3.初回セットアップを行う

いよいよプログラムを実行してみます。その前に、プログラムを実行するとき「アクセス権限」を承認する必要があります。

アクセス権限の承認のやり方がわからない場合は以下の記事を参考にしてください。

まず、「初回セットアップ専用」の initFetchFeed というプログラムを実行します。

この初回セットアップのプログラムでは、設定したRSS フィードを読み取り、スプレッドシートへ保存するまでをやってくれます。まだBluesky には投稿しません

Google Apps Script 初回セットアップinitFetchFeedの実行
Google Apps Script 初回セットアップinitFetchFeedの実行
実行がうまくいくとRSSフィードの内容が取得されます

うまく取得できると、「Feed」シートに今まで投稿した音声配信の情報が記録されます。

  • Title: タイトル
  • Link: 音声配信サービスに投稿したときのURL
  • Description: 音声配信サービスに投稿したときの概要欄の内容
  • FetchedAt: GASでRSSを取得した日時
  • Posted: Blueskyに投稿したかどうかのフラグ
    デフォルトはTRUE(過去に手動で投稿済みのものかもしれないので、一括投稿を防ぐため)
    • TRUE Blueskyに投稿済み(投稿済みとみなす)
      →Blueskyに投稿されない
    • FALSE Blueskyに投稿していない(していないとみなす)
      →これからBlueskyの投稿する
  • PostedAt: Blueskyに投稿された日時を記録する
  • BlueskyURL: Blueskyに自動投稿したときのURLを記録する

ここまでできたら、いよいよBlueskyの自動投稿設定を行います。

4. スクリプトプロパティの設定

コード内で直接書き換えるのではなく、スクリプトプロパティを使って、ご自身のBlueskyアカウント情報やLISTENのRSSフィードURLなどを設定します。これにより、コードを直接編集するリスクを減らせます。

Apps Script エディタ → プロジェクト設定 → スクリプト プロパティ を追加します。

Google Apps Script スクリプトプロパティの設定
Google Apps Script スクリプトプロパティの設定


以下のスクリプトプロパティを2つ追加します。

  • BSKY_USER: BlueskyのユーザーID
  • BSKY_APP_PWD: Blueskyで生成したアプリパスワード
Google Apps Script スクリプトプロパティの設定

入力完了したら「スクリプトプロパティを保存」ボタンを押します。

5.Blueskyへの自動投稿の動作確認

Blueskyに自動投稿できるかテストを行います。

スプレッドシートの「Feed」シートより、1件だけ「Posted」の列を「FALSE」に変更します。

Blueskyへの自動投稿の動作確認

GASエディタより、運用用のコードupdateFeedAndPostを実行します。

Blueskyへの自動投稿の動作確認
Image from Gyazo

updateFeedAndPost実行後、スプレッドシートの「Feed」シートより、「FALSE」に変更した行が「TRUE」に変わり、Blueskyに投稿されていることを確認します。

これが確認できれば、Blueskyへの投稿設定は完了です!

お好みに応じて、コード内にある以下の部分を変更して、ハッシュタグを設定します。

/**
 * Bluesky へ未投稿行を送信し、シートを更新する
 * シートに残っている Posted=FALSE の行だけ Bluesky へ投稿し
 * 成功したら Posted / PostedAt / BlueskyURL を更新する
 * 失敗時したら Posted は FALSE のまま残るので再実行でリトライ可
 * 本文は 300 文字制限を自動トリム
 * 外部リンクカードにサムネイル画像を付与(og:image → blob アップロード)
 */
function postUnsentToBluesky() {

// ~~~中略~~~
const tagsArr = ['#声日記', '#LISTEN', '#standfm', '#音声配信'];   // ←←←←←←←←←←←←←←←←←←← 好きなタグに変更できます

投稿がうまくできないケース

投稿がうまくできないケースを紹介しておきます。

以下の画面で運用用のコードupdateFeedAndPostを実行したときに、
[LOGIN] 401 : {"error":"AuthenticationRequired","message":"Invalid identifier or password"}
というエラーになることがあります。

Blueskyへの自動投稿の動作確認
Blueskyへの自動投稿の動作確認

このエラーは以下のような意味です。

  • [LOGIN]: ログイン処理中に発生したエラーであることを示しています。
  • 401: HTTPステータスコードで、「Unauthorized(認証エラー)」を意味します。ログイン情報(ユーザー名やパスワード)が正しくない場合に返されます。

対処方法としては以下の案があります。

  • 入力したユーザー名とアプリパスワードが正しいか確認する
  • ユーザ名に謎の文字列が入力されていないか確認する
  • アプリパスワードを再取得して入力しなおす

ユーザ名について、Blueskyからコピペをした場合に人間の目に見えない文字列が挿入されている場合があります。(最初にプログラムを作ったとき、ハマったポイントでした)

念の為、確認用のプログラムも作ってみたので合わせてお試しください。

まずはtestBlueskyLoginを実行して、実行ログを確かめます。

Blueskyへの自動投稿の動作確認
statusが401になってますね

401エラーの場合はdebugUserStringというコードも実行してみてください。

Blueskyへの自動投稿の動作確認

実行ログを確認し、末尾に 202c や 200f などが出たら制御文字(見えない文字列)が混入しています。見えない文字列が混入した場合はスクリプトプロパティのBSKY_USERの内容を一度削除し、面倒かもしれませんが手動で入力してみてください。

Google Apps Script スクリプトプロパティの設定

スクリプトプロパティを入力し直したら再度debugUserStringを実行し、見えない文字列がなくなっていることを確認します。

Blueskyへの自動投稿の動作確認

見えない文字列がまずtestBlueskyLoginを実行して、実行ログを確かめます。

Blueskyへの自動投稿の動作確認

うまくログインできると、BSKY_USERBSKY_APP_PWDの内容が表示され、status=200になっていればログインに成功しています。

ステップ3:トリガーの設定

最後に、作成したスクリプトが定期的に自動で実行されるように「トリガー」を設定します。

Blueskyへの自動投稿の動作確認 トリガーの設定

「トリガーを追加」します。

Blueskyへの自動投稿の動作確認 トリガーの設定

例えば「1時間ごと」や「毎日特定の時間」に実行するように設定できます。

Blueskyへの自動投稿の動作確認 トリガーの設定
この例は毎日17:00~18:00の間に実行

上記画像の例の場合は「毎日17:00~18:00の間に実行する」となっています。

プログラムが実行されると、LISTENやStandFMに新しい音声配信がある場合は、それをスプレッドシートの一番下の行に追加し、Blueskyに投稿してくれます。
音声配信に新しいエピソードがなく、スプレッドシートのPosted列に「FALSE」のフラグなどもない場合は、Blueskyには何も投稿されません。

これで設定は完了です!お疲れ様でした!

GASで自動投稿する際の注意点・コツ

GASを使った自動投稿はとても便利ですが、いくつか気をつけておきたいポイントがあります。

  • 実行頻度の調整:トリガーで設定するスクリプトの実行頻度に注意しましょう。LISTENの更新頻度に合わせて、あまり頻繁すぎない程度に設定するのがおすすめです。(例:1時間に1回、数時間に1回、1日1回など)
  • 投稿内容のカスタマイズ:GASなら投稿内容もかなり自由にカスタマイズできます。公開しているコードでは以下の内容が投稿されます。
    • 音声配信のタイトル
    • 音声配信のURL
    • 特定のハッシュタグ
  • Bluesky APIの仕様変更リスク:Blueskyはまだ比較的新しいSNSなので、APIの仕様が変更される可能性があります。もし急に動かなくなった場合は、APIのドキュメントを確認したり、エラーログを見たりする必要が出てくるかもしれません。
  • Google Apps Scriptの実行制限:GASには1日あたりの実行回数や実行時間などに制限があります。個人利用の範囲ではまず問題ありませんが、念のため頭の片隅に置いておきましょう。
  • エラーログの確認:もしうまく動作しない場合は、スクリプトエディタの「実行数」メニューからログを確認することで、原因究明の手がかりが得られます。
  • セキュリティ:アプリパスワードなどの認証情報は、スクリプトプロパティに保存するなど、コードに直接書き込まないようにしましょう。(今回の方法ではスクリプトプロパティを利用しています)
  • Blueskyのコミュニティガイドラインを守る:これはGASに限らずですが、Blueskyの利用規約やコミュニティガイドラインは遵守しましょう。

まとめ:GASでLISTEN・StandFMとBlueskyの連携を自動化しよう!

今回は、Google Apps Script (GAS) を使って、LISTENやStandFMの新しいエピソードをBlueskyに自動投稿する方法を、設定手順をご紹介しました。

「プログラミングなんてやったことないし、難しそう…」と感じた方もいるかもしれません。

しかし、一度設定してしまえば、あとはGASが健気に働いてくれて、あなたは面倒な手動投稿から解放されます。空いた時間で新しいエピソードの企画を練ったり、リスナーさんとの交流を楽しんだり…もっとクリエイティブな活動に時間を使いましょう!

毎回の少しの時間かもしれませんが、チリも積もれば山となるです。

この記事が、あなたの音声配信ライフとBlueskyライフをより快適で楽しいものにするための一助となれば、嬉しいです!

最後まで読んでくださりありがとうございました!

おまけ: 戦いの記録

#16 声日記 Notionにピュアライフダイアリーのようなページを作ってみたhttps://listen.style/p/manatee-poyon/dy29j4uu

まなてぃ (@manatee15jam.bsky.social) 2025-05-18T04:57:05.672Z

最初はURLリンクがうまくされずテキストのみで投稿されていました。

URLがリンクになってないURLカード(サムネイルみたいなやつ)も出したい

まなてぃ (@manatee15jam.bsky.social) 2025-05-18T04:58:38.738Z

スプレッドシート側に記録はできていそう

まなてぃ (@manatee15jam.bsky.social) 2025-05-18T04:59:29.295Z

そこから、ハッシュタグを付与し、URLをリンクさせ、カードを出せるようchatGPT先生と何度もやり取りしました。

一応GASのコードできた。・LISTENのRSSフィードを取得・スプレッドシートに記録・タイトルとURLと特定のハッシュタグを付けて自動投稿一応17:00~18:00くらいにトリガーをセットしたので自動で投稿されていれば動作確認完了だな。見守ろう。

まなてぃ (@manatee15jam.bsky.social) 2025-05-18T06:56:34.064Z

chatGPT様が天才すぎるのでノンプログラマーずぼら主婦でも、とりあえず動くコードが作れてしまうの神…!ありがとう!!うまく動いたらブログに公開するか~

まなてぃ (@manatee15jam.bsky.social) 2025-05-18T06:58:24.880Z

ということで公開してみました!

まなてぃ

どうでもいいけど、Xのポストより、Blueskyのポストの方が、安定してWordPressに埋め込めていいよね~笑

(スポンサーリンク)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

訪問いただきありがとうございます。
まなてぃと申します。
元IT企業の人→フリーランス→派遣OL&主婦。ブログを書いています。最近はデータ集計や分析のお仕事をしています。
このブログでは、主婦(主夫)さんに向けた、ライフスタイルに関すること、自身のスキルアップのことなど、ざっくばらんに発信できればいいなと思っています。よろしければご覧ください。

コメント

コメント一覧 (1件)

目次