なぜGoogle Apps Script (GAS) で自動化するの?
「そもそも、なんで自動投稿する必要があるの?」と感じる方もいるかもしれませんね。
もちろん、手動でも投稿はできますが、自動化にはこんなメリットがあります!
- 手間と時間を大幅に削減できる!:毎回手動で投稿する手間がなくなる
- 投稿忘れを防げる!:うっかり投稿を忘れてしまうことがなくなる
- Blueskyのアカウント活用:コンスタントな情報発信ができ、アカウントを活性化できる
私も最初は、IFTTT(イフト)やZapier(ザピアー)といった有名な自動化サービスを検討しました。これらは設定が比較的簡単な反面、
- Blueskyとの連携が公式でサポートされていなかったり、情報が少なかったりする(2025年5月現在)
- 無料でできる範囲に制限がある(例:Xの連携は有料プランのみ、実行回数制限など)
といった点が気になりました。お金をかけずに、なるべく無料で実現したいという希望がありました。
そこで、Google Apps Script (GAS) を使おうと思いました!
GASなら、以下のようなメリットがあると考えました。
- 無料で利用できる(GoogleアカウントさえあればOK!)
- Googleスプレッドシートと連携して、投稿履歴の管理なども柔軟にできる
- JavaScriptベースなので、プログラミングの知識が少しあり、ChatGPT先生に頼れば、かなり自由にカスタマイズできる
実際に、こちらの素晴らしい記事を参考にさせていただき、chatGPT先生と会話しながら、私でも実装することができました!
GASでRSSを取得してブログの更新をBlueskyに投稿する
「ちょっと難しそう…」と感じるかもしれませんが、この記事で手順を追って解説してみたいと思います。
準備するもの
自動投稿システムを構築する前に、以下のものを準備しておきましょう。
- LISTENやStandFMのアカウントと、ご自身の番組のRSSフィードURL
- Blueskyのアカウント
- Blueskyのユーザーハンドル(例:
yourname.bsky.social) - Blueskyのアプリパスワード(これは通常のログインパスワードとは異なります。Blueskyの設定画面から発行が必要です)
- Googleアカウント (GoogleスプレッドシートとGoogle Apps Scriptを利用するため)
- LISTENにアクセスし、プロフィールアイコンをクリック
- ダッシュボード ボタンをクリック
- 配信サービス RSSのアイコン をクリック
- RSSのURLをコピー
StandFMのRSSフィード取得方法
Blueskyのアプリパスワード取得について
Blueskyにログインし「設定」を押す
「プライバシーとセキュリティ」を押す
「アプリパスワード」ボタンを押す
「アプリパスワードを追加」ボタンを押す
アプリパスワードに名前をつけ、「次へ」ボタンを押す(なんのアプリのときに生成したパスワードかわかるような名前を付けておくといいかもです)
アプリパスワードが表示されるので、なくさない場所にメモしておく
※ご注意このパスワードはこの画面の1回しか表示されません!メモする前に閉じてしまった場合は「アプリパスワードを追加」ボタンを押す手順から新規作成しましょう
GASでLISTENやStandFMの更新をBlueskyに自動投稿しよう
ここからは、実際にGoogle Apps Scriptを使って自動投稿システムを構築していく手順を、写真を交えながら詳しく解説します。
ステップ1:Googleスプレッドシートの準備
まずは、投稿したエピソードの情報を記録・管理するためのGoogleスプレッドシートを作成します。
ステップ2:Google Apps Scriptの設定とコードの貼り付け
次に、Googleスプレッドシートに紐づいたGoogle Apps Scriptを設定し、自動投稿のためのコードを記述(コピペ)していきます。
1. スクリプトエディタを開く
スプレッドシートのメニューバーから「拡張機能」>「Apps Script」を選択します
こんな感じの画面が開きます。
2. コードを貼り付ける
以下のコードをスクリプトエディタに貼り付けます。
コードを見る(長いよ)クリックしたら開きます
const FEED_URL = 'ここにRSSフィードのURLをいれる';
const SHEET_NAME = 'Feed';
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, '', ''
]);
if (newRows.length === 0) { Logger.log('新規 0 行'); return; }
sheet.getRange(sheet.getLastRow() + 1, 1, newRows.length, 7)
.setValues(newRows);
Logger.log(`初回取得: ${newRows.length} 行を追加(投稿は行いません)`);
}
function updateFeedAndPost() {
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('新規記事なし');
}
postUnsentToBluesky();
}
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) {
return processingRSS10(root)
} else if (atom.length > 0) {
return processingAtom(root)
} else {
return processingRSS20(root)
}
} else {
return []
}
}
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) || ''
}));
}
function processingRSS20(root) {
const items = root.getChild('channel').getChildren('item');
return items.map(item => ({
title: item.getChildText('title') || '',
link: item.getChildText('link') || '',
description: item.getChildText('description') ||
(item.getChildText('content:encoded') || '')
}));
}
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) || ''
}));
}
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;
data.forEach((row, i) => {
if (row[col.posted] === true) return;
const url = row[col.link];
const title = trimTitle(row[col.title], 300 - url.length - tagLen - 1);
const text = `${title}\n${url}\n\n${tagsStr}`;
const facets = buildFacets(text, url, tagsArr);
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) {
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}`); }
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 } : {})
}
}
};
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}`);
}
});
}
function buildFacets(text, link, tags) {
const utf8Len = s => Utilities.newBlob(s).getBytes().length;
const facets = [];
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 }]
});
}
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) + '…';
}
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; }
}
function atUriToWebUrl(atUri, handle) {
const rkey = atUri.split('/').pop();
return `https://bsky.app/profile/${handle}/post/${rkey}`;
}
function testBlueskyLogin() {
const USER = PropertiesService.getScriptProperties().getProperty('BSKY_USER')?.trim();
const PWD = PropertiesService.getScriptProperties().getProperty('BSKY_APP_PWD')?.trim();
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());
}
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(' '));
}
const FEED_URL = ‘ここにRSSフィードのURLをいれる‘; // 取得したい RSSフィード
の部分に、取得したいRSSフィードのURLを入力します。
こんなかんじ
ここまでできたら、ドライブに一度保存しましょう。
3.初回セットアップを行う
いよいよプログラムを実行してみます。その前に、プログラムを実行するとき「アクセス権限」を承認する必要があります。
アクセス権限の承認のやり方がわからない場合は以下の記事を参考にしてください。
まず、「初回セットアップ専用」の initFetchFeed というプログラムを実行します。
この初回セットアップのプログラムでは、設定したRSS フィードを読み取り、スプレッドシートへ保存するまでをやってくれます。まだBluesky には投稿しません。
実行がうまくいくと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 エディタ → プロジェクト設定 → スクリプト プロパティ を追加します。
以下のスクリプトプロパティを2つ追加します。
- BSKY_USER: BlueskyのユーザーID
- BSKY_APP_PWD: Blueskyで生成したアプリパスワード
入力完了したら「スクリプトプロパティを保存」ボタンを押します。
5.Blueskyへの自動投稿の動作確認
Blueskyに自動投稿できるかテストを行います。
スプレッドシートの「Feed」シートより、1件だけ「Posted」の列を「FALSE」に変更します。
GASエディタより、運用用のコードupdateFeedAndPostを実行します。
updateFeedAndPost実行後、スプレッドシートの「Feed」シートより、「FALSE」に変更した行が「TRUE」に変わり、Blueskyに投稿されていることを確認します。
これが確認できれば、Blueskyへの投稿設定は完了です!
お好みに応じて、コード内にある以下の部分を変更して、ハッシュタグを設定します。
function postUnsentToBluesky() {
const tagsArr = ['#声日記', '#LISTEN', '#standfm', '#音声配信'];
投稿がうまくできないケース
投稿がうまくできないケースを紹介しておきます。
以下の画面で運用用のコードupdateFeedAndPostを実行したときに、[LOGIN] 401 : {"error":"AuthenticationRequired","message":"Invalid identifier or password"}というエラーになることがあります。
このエラーは以下のような意味です。
- [LOGIN]: ログイン処理中に発生したエラーであることを示しています。
- 401: HTTPステータスコードで、「Unauthorized(認証エラー)」を意味します。ログイン情報(ユーザー名やパスワード)が正しくない場合に返されます。
対処方法としては以下の案があります。
- 入力したユーザー名とアプリパスワードが正しいか確認する
- ユーザ名に謎の文字列が入力されていないか確認する
- アプリパスワードを再取得して入力しなおす
ユーザ名について、Blueskyからコピペをした場合に人間の目に見えない文字列が挿入されている場合があります。(最初にプログラムを作ったとき、ハマったポイントでした)
念の為、確認用のプログラムも作ってみたので合わせてお試しください。
まずはtestBlueskyLoginを実行して、実行ログを確かめます。
statusが401になってますね
401エラーの場合はdebugUserStringというコードも実行してみてください。
実行ログを確認し、末尾に 202c や 200f などが出たら制御文字(見えない文字列)が混入しています。見えない文字列が混入した場合はスクリプトプロパティのBSKY_USERの内容を一度削除し、面倒かもしれませんが手動で入力してみてください。
スクリプトプロパティを入力し直したら再度debugUserStringを実行し、見えない文字列がなくなっていることを確認します。
見えない文字列がまずtestBlueskyLoginを実行して、実行ログを確かめます。
うまくログインできると、BSKY_USERとBSKY_APP_PWDの内容が表示され、status=200になっていればログインに成功しています。
ステップ3:トリガーの設定
最後に、作成したスクリプトが定期的に自動で実行されるように「トリガー」を設定します。
「トリガーを追加」します。
例えば「1時間ごと」や「毎日特定の時間」に実行するように設定できます。
この例は毎日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
ということで公開してみました!