ブログの投稿と連動してX(旧Twitter)に自動投稿するアプリのプロトタイプを紹介します。Google Apps Script(GAS)を使ってRSSフィードから情報を取得し、Azure OpenAI Serviceを利用して投稿内容を生成します。SNS担当者の負担を減らすためのシステム設計と実装方法を詳しく解説します。
挨拶
ども!生成AIのアドベントカレンダーが始まってブログ執筆を全力で取り組んでいる龍ちゃんです。検証は終わっているけど、ブログ化していないネタが大量にあるので指が大忙しです。
今回のネタは、ブログのPRに関係する活動のネタになります。内容としては、「RSSからブログの更新を取得して、Azure OpenAI Serviceを用いてブログの投稿内容を作成」する内容となります。まだ、検証の段階なので今回の記事ではTwitter APIを使用した自動投稿については扱いません。あらかじめご了承ください。今月中にはそこまで接続したアプリにしてまとめておきます。
それでは、タイトルの回収に参りたいと思います。
モチベーション
今回の記事でのモチベーションは、「SNS担当者の負担を減らす」というものになります。お恥ずかしいところですが、文面はAIが考えていますが投稿は人力で行っています。エンジニアが大量にいる会社なので、その辺をシステムチックに解消したいところが出発点となります。
理想とする姿としては、「ブログの投稿と連動して、決まった時間にXへポストする。もしブログの投稿がない場合は、宣伝に切り替える」となります。
今回の記事の内容では、「ブログの更新を取得する」と「AIに文面を考えてもらう」という部分までのプロトタイプになります。
設計
今回は、プロトタイプということもありGoogle Apps Script(以降:GAS)を使用しています。全体像としては、以下の画像のイメージになります。
GASでやることは以下の三つになります。
- RSSへ問い合わせをして記事情報を取得
- URLから記事の内容を取得する
- 記事の内容からAOAIにXへのポスト内容を作成依頼
記事の情報は、Google Spreadsheet上ですべて管理を行っています。重複した情報を投稿しないようになどの工夫が入っているので、実装の際に紹介をしていきたいと思います。
今回のゴールとしては、以下のようなスプレッドシートが出来上がります。
ヘッダー情報としては、Title/Link(記事URL)/PubDate(執筆日)/RowData(記事HTML)/PostData(Xへのポスト)になります。
実装
各段階に分けて、コードの記載を行っていきます。スプレッドシートにアクセスする部分に関しては共通処理なので、シート名を渡すと有効なシートが返答されるように関数化しておきます。
const accessSheet = (sheetName) => {
const file = SpreadsheetApp.getActiveSpreadsheet()
const sheet = file.getSheetByName(sheetName)
return sheet
}
RSSから情報を取得する
ここで実装していく内容としては、「RSSフィードに問い合わせを行い、すでに取得している情報であればそぎ落としてスプレッドシートに保存」になります。
まずは、RSSフィードから情報を取得する部分になります。サンプルとしては、弊社のブログのRSSフィードを利用します。(ぜひウォッチしてね(>_<))
const getRSSFeed = () => {
// RSSフィードのURLを指定
const rssUrl = '<https://tech-lab.sios.jp/feed>'; // ここにRSSフィードのURLを入力してください
// RSSフィードを取得
const response = UrlFetchApp.fetch(rssUrl);
const xml = response.getContentText();
const document = XmlService.parse(xml);
const root = document.getRootElement();
// RSSフィードのエントリを解析
const items = root.getChild('channel').getChildren('item');
const data = [];
items.forEach(function (item) {
const title = item.getChild('title').getText();
const link = item.getChild('link').getText();
const pubDate = new Date(item.getChild('pubDate').getText());
data.push([title, link, pubDate]);
});
return data
}
こちらでは、XMLで返答されたデータをJSONに変換して構造化しています。この関数を利用して、重複削除を行うコードが以下になります。
function stackSheetFromRSSFeed() {
// 定期実行することでRSSからの情報をスプレッドシートに情報をためる
// GoogleスプレッドシートのIDを指定
const sheet = accessSheet("元データ")
const lastRow = sheet.getLastRow()
// Logger.log(lastRow)
// 重複削除の必要があるため現在のリストを取得
const linkList = sheet.getRange(2, 2, lastRow - 1, 1).getValues().map((row) => row[0])
// RSSから情報取得
const dataFromRSS = getRSSFeed()
// 重複削除処理
const data = []
dataFromRSS.forEach((value) => {
const link = value[1]
if (!linkList.includes(link)) data.push(value)
})
// 更新なしの場合は処理終了
if (data.length == 0) return
// シートに書き込み処理
sheet.getRange(lastRow, 1, data.length, data[0].length).setValues(data)
}
すでに存在しているURLを一覧で取得し、そこから重複を削除してデータの保存を行っています。
URLから記事の情報を取得する
ここで、実装していく内容としては「記事のURLから記事のHTML情報を取得して、スプレッドシート保存」になります。
なぜ?一度HTMLの情報を取得するのか?
AOAIでは、URLから情報を取得することができないからです。一度テキストデータとして取得する必要があります。この辺は初耳情報でしたね。
const getPageHTML = (url) => {
var response = UrlFetchApp.fetch(url);
var html = response.getContentText();
// HTMLを解析
var document = HtmlService.createHtmlOutput(html).getContent();
// 必要な部分を抽出(この記事の内容が <section> タグ内にあると仮定します)
var content = '';
var regex = /<section class="entry-content"[^>]*>([\\\\s\\\\S]*?)<\\\\/section>/;
var match = regex.exec(document);
if (match && match[1]) {
content = match[1];
} else {
content = '記事の内容を取得できませんでした。';
}
return content
}
ここでは、GASの機能を使用してHTMLを取得して正規表現で本文部分のみを取得しています。
こちらを用いて、重複削除や情報を取得済みのものに関しては情報を取得しない処理などを追記したものを記載します。
function createRowData() {
const sheet = accessSheet("元データ")
const lastRow = sheet.getLastRow()
const header = ["Title","Link","PubDate","RowData"]
const dataList = sheet.getRange(2, 1, lastRow - 1, header.length).getValues()
dataList.forEach((value, index) =>{
const [,link,,rowData] = value
// すでに情報がある場合はスルー
if(rowData!="")return
const htmlData = getPageHTML(link)
value[3] = htmlData
// 書き込み処理
sheet.getRange(index + 2, 1, 1, header.length).setValues([value])
// HTML取得に関しては攻撃にもなるので人間的な挙動にしてます。
Utilities.sleep(1000);
})
}
スプレッドシートに記載している情報がない場合は、スルーすることで取得済み判定を行っています。
記事内容からXへの投稿内容を取得する
ここで実装する内容としては、「取得済みのHTML情報からAOAIに問い合わせを行い、Xへの投稿内容を作成」になります。問い合わせに使用するプロンプトは以下になります。
System
あなたはSNS担当者です。送付した内容からXの投稿を作成してください。内容は日本語の300文字以内で作成してください。ブログの導入などのリンクはつける必要はありません。
---
HTML情報
---
まずは、AOAIに問い合わせする処理を関数として切り出します。
const createPostUseAOAI = (text) => {
const apiEndpoint = PropertiesService.getScriptProperties().getProperty("AOAI_API_URL");
const modelName = PropertiesService.getScriptProperties().getProperty("AOAI_API_MODEL");
const apiVersion = PropertiesService.getScriptProperties().getProperty("AOAI_API_VERSION");
const apiKey = PropertiesService.getScriptProperties().getProperty("AOAI_API_KEY");; // ここにAzure APIキーを入力
const apiUrl = `${apiEndpoint}/openai/deployments/${modelName}/chat/completions?api-version=${apiVersion}`; // ここにAzure OpenAIエンドポイントURLを入力
// OpenAI APIに送信するデータ
const payload = {
model: modelName, // 'model'パラメータのみ使用
messages: [{ role: "system", content: "あなたはSNS担当者です。送付した内容からXの投稿を作成してください。内容は日本語の300文字以内で作成してください。ブログの導入などのリンクはつける必要はありません。" }, { role: "user", content: text }], // プロンプトをメッセージリストの形式に変更
};
const options = {
method: 'POST',
headers: {
"Content-Type": "application/json",
"api-key": apiKey
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(apiUrl, options);
const responseJson = JSON.parse(response.getContentText());
const res = responseJson.choices[0].message.content.trim();
return res
}
catch (e) {
Logger.log(`error:${e}`)
return
}
}
AOAIのREST APIを使用して実装しています。またシークレット情報を記載するわけにはいきませんので、スクリプトプロパティとして保存しています。書き方設定周りはおいておきます。
Google Apps Scriptを最大限活用していきたいなぁ~こちらの関数を利用して、重複削除などを追加しておきます。
function createPostData(){
const sheet = accessSheet("元データ")
const lastRow = sheet.getLastRow()
const header = ["Title","Link","PubDate","RowData","PostData"]
const dataList = sheet.getRange(2, 1, lastRow - 1, header.length).getValues()
dataList.forEach((value, index) =>{
const [,link,,rowData,postData] = value
// すでに情報がある場合はスルー
if(postData!="")return
const postDataAOAI = createPostUseAOAI(rowData)
// AOAI側のエラーの場合は作成しないので処理終了
if(!postDataAOAI) return
// 定型文の情報を追記する
const createPost = "ブログ紹介:Botによる自動投稿です \\\\n" + postDataAOAI + "\\\\n"+ link
Logger.log(createPost)
value[4] = createPost
sheet.getRange(index + 2, 1, 1, header.length).setValues([value])
// 鬼のように叩かないようにリスクヘッジ
Utilities.sleep(1000);
})
}
処理としては、ほぼ先ほどの「URLから記事の情報を取得する」と同一です。
運用に必要な考慮事項
検証したこととしては、以上です。ここからは、運用まで検討するにあたって必要な考慮事項の整理をしたいと思います。プロトタイプなので、これからの作成の礎を作っておきます。
- 定期実行にはラグがある
- 次に投稿されるメッセージの管理をどうするか?
- 投稿済みのフラグの管理をどうするか?
- エラーハンドリング
定期実行にはラグがある
GASの問題ですが、定期実行は数分単位のずれがあります。今回の構成では、スプレッドシートのセルに情報があるかどうかで処理をしているので問題はありません。効果的な実行をするためには、RSSから情報取得の終了時に次の処理をトリガーを発行する数珠繋ぎ方式などありそうです。
投稿予定の表示
スプレッドシートで管理をしているので、投稿予定の内容を見るには見にくいと思います。この辺は裏の構成を含めて回収する必要がありそうですね。フロントの画面でも作って管理さえすれば特に問題はなさそうです。管理画面的なものを作ると時間は食いますが、やはりスプレッドシートだと見にくい側面もあるので、次の課題にしようと思います。
投稿済みのフラグ管理をどうするのか?
これは、スプレッドシートでの管理をやめるしかないのではないでしょうか?いや考えれば行けると思うんですが、データベースに置換してしまった方が話が速い気がするんですよね。
エラーハンドリング
今回は、エラーが起きても処理を落とさない力技を行っています。GASの管理画面で一個ずつ確認するしかないのが辛いところです。
単純なエラーだけでなく、Azure側のエラーに関してはどうしようもないので、構成をAzureに寄せて解決するのが一番手っ取り早いです。
おわりに
ども!久しぶりの実装と執筆でした。プロトですが、効果がありそうなのでがっつり作っていきたいかなと思います。
では!また!