3段のリプライツリー(リプツリー)をbotで作ってみよう
今回の記事は,リプライ形式でbot投稿するサンプルコードです。
Googleスプレッドシート(GAS)からBluesky APIにアクセスし,無料で動かせます。
リプツリーって便利 ですよね!
1メッセージ内に収まらない長い文章でも,小分けに分割して
複数リプライに分ければ投稿できます。
そうすると,SNSをまるでブログみたいに使用できます。
リプツリー1個全体が,ブログの記事1つぶんに対応するわけで
botを使った情報発信の幅が広がります。
ですので,今回のリプライ機能は
「単発で短文を投稿するだけ」よりも,もっと進んだSNSの使い方をしてみたい場合
必須の機能だ…と言えます。
※リプライ形式ではない投稿の方法は,下記の記事をご覧ください。
普通の投稿の方法 :
たった100行で,Blueskyにbot投稿するGASサンプルコード引用投稿(引用RT,QT)の方法 :
Blueskyで①投稿メッセージのURLを取得したり ②引用投稿したりするGASサンプル
今回のサンプルコードでは,botで下記のような「3段のリプツリー」を作成できます。
- 「テスト投稿です。リプツリーの1段目です。」
- 「テスト投稿です。リプツリーの2段目です。」
- 「テスト投稿です。リプツリーの3段目です。」
1回のコード実行で,これら3つのテスト投稿を含むスレッド(リプツリー)を作れるんです。
さっそく動かしてみましょう!
たった450行のサンプルコード
コード冒頭にあるアカウント名とパスワードは,ご自分のアカウントの認証情報に書き換えておいてください。
// 「Bluesky上でリプツリーを作成するサンプルコード」
//
// ・Googleスプレッドシート(GAS)から,
// Bluesky上に1つテストメッセージを投稿します。
// そこに自分でリプライを付け,さらにリプライにまたリプライを付けます。
// 合計で3段のリプツリーとなります。
// ・必要となるコードの基本的な動作原理を理解する事が目的です。
// ・サンプルコード全体でぴったり(無理やり)450行となっています。
// 2026.9.9. @rwanda_go_tan
// ここに自分のBlueskyアカウント名とアプリパスワードを記述する。
const MY_BLUESKY_USERNAME = "~~.bsky.social"; // 例: hoge.bsky.social
const MY_BLUESKY_PASSWORD = "~~"; // Blueskyで「設定」→「プライバシーとセキュリティ」より
// メイン処理
function hoge(){
console.log("Bluesky上にリプツリーを作成します。");
// まずセッションを作成し,認証トークンを取得
const access_jwt = bluesky_createSession();
// 普通の投稿を実行し,投稿結果の(http形式の)URLを得る。
let http_response_json = bluesky_postOneMessage({
bs_post_message : "テスト投稿です。リプツリーの1段目です。" + ( new Date() ).toISOString(),
access_jwt : access_jwt
});
let posted_message_url = bluesky_getMessageUrlFromHttpResponseOnPostSuccess({
my_bluesky_username : MY_BLUESKY_USERNAME,
http_response_json : http_response_json
});
// 自分の投稿に対して複数回,リプライを付ける。
for( let i = 2; i <= 3; i ++ ){
// リプライしたい投稿のURLを指定して,リプライを投稿する。
http_response_json = bluesky_postOneMessage({
bs_post_message : "テスト投稿です。リプツリーの" + i + "段目です。" + ( new Date() ).toISOString(),
access_jwt : access_jwt,
reply_to_url : posted_message_url // このURLの投稿がリプライの親となる
});
posted_message_url = bluesky_getMessageUrlFromHttpResponseOnPostSuccess({
my_bluesky_username : MY_BLUESKY_USERNAME,
http_response_json : http_response_json
});
}
console.log("Bluesky上にリプツリーを作成完了。");
}
// Bluesky上に投稿された1件の投稿メッセージについて
// ブラウザ上で確認できるURLから,その投稿の詳細情報を取得する。
// 詳細情報の中には,at-uri,cid,
// およびその投稿が属するリプツリーのルート投稿情報などが含まれる。
function bluesky_getPostDetailInfoByUrl( arg_obj ){
// 引数を解釈
const post_url = arg_obj.post_url;
const access_jwt = arg_obj.access_jwt;
let getPostThread_depth = arg_obj.getPostThread_depth;
if( ! getPostThread_depth ) getPostThread_depth = 0;
let getPostThread_parentHeight = arg_obj.getPostThread_parentHeight;
if( ! getPostThread_parentHeight ) getPostThread_parentHeight = 0;
// 投稿のURLから,Blueskyハンドル名と投稿の識別子を抽出
const match_result = post_url.match( /profile\/([^\/]+)\/post\/([^\/]+)/ );
if( ! match_result ){
throw "無効なBluesky投稿URLです。";
}
const target_bluesky_username = match_result[1];
const target_post_id = match_result[2];
// 暫定的な at-uri を組み立てる。
// (この時点では,uri中でBlueskyユーザー名の部分がDID形式になっていない。
// APIから返却されるat-uriでは,Blueskyユーザー名の部分が did:plc:~ 形式で置き換えられる。)
const temporary_at_uri = "at://"
+ target_bluesky_username
+ "/app.bsky.feed.post/"
+ target_post_id
;
// Bluesky API上の getPostThread エンドポイントのURLを構築する。(GET形式)
const http_params = {
uri : temporary_at_uri,
depth : getPostThread_depth,
parentHeight : getPostThread_parentHeight,
};
const all_query_strings = Object.keys( http_params )
.map(function( param_key ){
// key=value 形式にする
const param_value = http_params[ param_key ];
const one_query_string = encodeURIComponent( param_key )
+ "="
+ encodeURIComponent( param_value )
;
return one_query_string;
})
.join("&")
;
const api_url_with_get_params = `https://bsky.social/xrpc/app.bsky.feed.getPostThread`
+ "?"
+ all_query_strings // GETパラメータを付与
;
// Bluesky HTTP API Reference — Bluesky App
// /xrpc/app.bsky.feed.getPostThread
// https://endpoints.bsky.app/#bluesky-app/tag/appbskyfeed/GET/xrpc/app.bsky.feed.getPostThread
// このエンドポイントへのリクエストとレスポンスの仕様が記載されている。
// メッセージ情報を取得するための Bluesky API にアクセス
const url_fetch_options = {
method : "GET",
headers : {
"Authorization" : "Bearer " + access_jwt
},
muteHttpExceptions : true
};
const http_response_obj = UrlFetchApp.fetch(
api_url_with_get_params,
url_fetch_options
);
// 正常にアクセスできたか?
if( http_response_obj.getResponseCode() !== 200 ){
throw "APIアクセスでエラーが発生しています。 " + http_response_obj.getContentText();
}
/*
// 正常にアクセスできた場合,レスポンス本文を見やすくインデントして表示
console.log(
"content_text : "
+ JSON.stringify(
JSON.parse( http_response_obj.getContentText() ),
null,
2 // 半角スペース2つずつ使用してインデント
)
);
*/
// 受信データから必要な情報を(重要な物のみ)抽出
const json_http_response_content = JSON.parse( http_response_obj.getContentText() );
const target_post_data = json_http_response_content["thread"]["post"];
// 着目している投稿の AT-uri と cid
const target_post_at_uri = target_post_data["uri"];
const target_post_cid = target_post_data["cid"];
// 着目している投稿をリプライツリーの一部とみなし,
// その属しているツリーの先頭部分(スレッドのルート)の情報を取得する。
// (この情報は,着目している投稿に対してリプライを投稿したい場合にあとで必要になる。)
let thread_root_post_at_uri = target_post_at_uri; // まず暫定的に,自分自身をルートとしておく
let thread_root_post_cid = target_post_cid;
const thread_info = json_http_response_content["thread"];
if(
thread_info["post"] // post属性の中にルート情報が格納されている場合がある。
&&
thread_info["post"]["record"]
&&
thread_info["post"]["record"]["reply"]
&&
thread_info["post"]["record"]["reply"]["root"] // JSON内の属性をここまでたどれるか。
){
// AT-uri と cid を取得・保管
thread_root_post_at_uri = thread_info["post"]["record"]["reply"]["root"]["uri"];
thread_root_post_cid = thread_info["post"]["record"]["reply"]["root"]["cid"];
}
else
if(
// APIの返却形式によって階層が変わり,下記の形式のケースもある。
thread_info["record"] // post属性が無く,いきなりrecord属性が始まる場合がある。
&&
thread_info["record"]["reply"]
&&
thread_info["record"]["reply"]["root"] // JSON内の属性をここまでたどれるか。
) {
// AT-uri と cid を取得・保管
thread_root_post_at_uri = thread_info["record"]["reply"]["root"]["uri"];
thread_root_post_cid = thread_info["record"]["reply"]["root"]["cid"];
}
// 呼び出し元が利用しやすいような形式で返す
return {
uri : target_post_at_uri,
cid : target_post_cid,
author_name : target_post_data["author"]["displayName"],
record_created_at : target_post_data["record"]["createdAt"],
record_text : target_post_data["record"]["text"],
like_count : target_post_data["likeCount"],
repost_count : target_post_data["repostCount"], // リポスト数と引用数はブラウザ上では合算表示されている
quote_count : target_post_data["quoteCount"],
reply_count : target_post_data["replyCount"],
bookmark_count : target_post_data["bookmarkCount"],
// 該当する投稿が属するリプツリーの先頭の投稿(スレッドルート)についての情報
thread_root_post_at_uri : thread_root_post_at_uri,
thread_root_post_cid : thread_root_post_cid
};
}
// Blueskyのセッションを作成する。
// 認証に使うアクセストークン文字列(accessJwt)を返す。
function bluesky_createSession() {
// セッション作成用のAPIにアクセスする
const api_response = UrlFetchApp.fetch(
"https://bsky.social/xrpc/com.atproto.server.createSession",
{
method : "POST",
headers : {
"Content-Type" : "application/json"
},
payload : JSON.stringify({
identifier : MY_BLUESKY_USERNAME,
password : MY_BLUESKY_PASSWORD
})
}
);
// APIからのレスポンスは正常か
if( api_response.getResponseCode() === 200 ){
// レスポンスをJSONとしてパース
const session_obj = JSON.parse( api_response.getContentText() );
console.log("Blueskyセッション作成に成功。");
// 認証に使うアクセストークン文字列を返す
return session_obj.accessJwt;
// accessJwtとは,以降のAPIリクエスト中に認証に使う「短寿命のアクセストークン」を指す。
//
// ※参考文献 ATプロトコルでの認証方法について: HTTP API (XRPC)
// https://atproto.com/ja/specs/xrpc
// Authenticationの欄から引用:
// "Most requests should be authenticated using an access JWT,
// but the validity lifetime for these tokens is short.
// Every couple minutes, a new access JWT can be requested"
}
else
{
// 例外を投げ処理中断
throw "Blueskyセッション作成に失敗。" + api_response.getContentText();
}
}
// Blueskyにbot API経由でメッセージを1つ投稿する。
// 引数のオプションで,特定の投稿にリプライを付けることもできる。
function bluesky_postOneMessage( argument_obj ){
// この関数の引数を解釈
const bs_post_message = argument_obj.bs_post_message;
const access_jwt = argument_obj.access_jwt;
const reply_to_url = argument_obj.reply_to_url;
console.log( "Blueskyにメッセージを投稿します。投稿内容: " + bs_post_message );
// UrlFetch用のパラメータを構築する
let url_fetch_params = {
method : "POST",
headers : {
"Authorization" : "Bearer " + access_jwt, // 認証トークンを渡す
"Content-Type" : "application/json"
},
// APIに送る各種パラメータ類はこの中に記載する (JSONではなく文字列形式)
payload : null // とりあえずnullにしておいて,以降で値を組み立ててから代入する
};
// payloadを組み立てる
let payload_obj = {
// 自分のBlueskyアカウント名を指定する。
repo : MY_BLUESKY_USERNAME,
// フィードへの投稿である旨を明示する。
collection : "app.bsky.feed.post",
// 具体的な投稿の内容を記載する。
record : {
// 投稿本文
text : bs_post_message,
// 作成日時
createdAt : ( new Date() ).toISOString()
}
};
// リプライしたい投稿のURLが指定されている場合
if( reply_to_url ){
// リプライしたい投稿が属しているリプツリーのルート投稿などの情報を取得する
const target_post_detail = bluesky_getPostDetailInfoByUrl({
post_url : reply_to_url,
access_jwt : access_jwt
});
// リプライ指定のために必要な情報を抽出
const target_post_at_uri = target_post_detail.uri;
const target_post_cid = target_post_detail.cid;
const thread_root_post_at_uri = target_post_detail.thread_root_post_at_uri; // ツリーのルートuri
const thread_root_post_cid = target_post_detail.thread_root_post_cid; // ツリーのルートcid
// fetch用のパラメータにリプライ関連の情報を指定する
payload_obj["record"]["reply"] = {
// リプツリーの先頭(root)の投稿についての情報
root : {
uri : thread_root_post_at_uri,
cid : thread_root_post_cid
},
// リプライが付く投稿についての情報
// (※その投稿は,今回のリプライが付く事によって親parentとなる。)
parent : {
uri : target_post_at_uri,
cid : target_post_cid
}
};
console.log("リプライ用のパラメータをセットしました。");
// NOTE:
// リプライ投稿時のJSON指定法は,下記の公式マニュアルを参照。
// https://bsky.network/docs/bluesky-api/creating-a-post/#replies
}
// payloadを組み立て終わったら文字列として保管する
url_fetch_params["payload"] = JSON.stringify( payload_obj );
// メッセージ投稿用のAPIにアクセスし,HTTPレスポンスを受信する
const http_response_obj = UrlFetchApp.fetch(
"https://bsky.social/xrpc/com.atproto.repo.createRecord",
url_fetch_params
);
// 受信したHTTP本文
const http_response_texts = http_response_obj.getContentText();
// NOTE:
// 通信エラーが発生している場合は,この時点ではcontentはJSONではなくHTMLであったりする。
// そのため,このタイミングでJSON.parseしようとするとHTMLをJSONとして解釈できないエラーになり得る。
// JSON.parseするよりも前に,レスポンスが正常かどうかのチェックが要る。
// APIからのレスポンスは正常か
if( http_response_obj.getResponseCode() === 200 ){
console.log("Blueskyメッセージ投稿に成功。");
// 通信に成功していたら,HTTPレスポンスをJSONとしてパース可能だとわかる
const http_response_json = JSON.parse( http_response_texts );
/*
// 受信したJSONを整形してデバッグ出力
const response_json_as_indented_text = JSON.stringify(
http_response_json,
null,
2 // 半角スペース2個でインデント整形して見やすくする
);
console.log( "HTTPレスポンスをデバッグ出力します。\n" + response_json_as_indented_text );
// ※出力内容はそれなりに長いので注意
*/
// 連続して通信するとエラーになりやすいので,少し待機
Utilities.sleep(3000);
// JSONを返す
return http_response_json;
}
else
{
// 例外を投げ処理中断
throw "Blueskyメッセージ投稿に失敗。" + http_response_texts;
}
}
// メッセージの投稿成功時のHTTPレスポンスを使い,投稿結果のURLを取得する。
function bluesky_getMessageUrlFromHttpResponseOnPostSuccess( argument_obj ){
// 引数を解釈
const my_bluesky_username = argument_obj.my_bluesky_username;
const http_response_json = argument_obj.http_response_json
// APIから返ってきたレスポンスを見て,投稿の識別子を取得する
const posted_message_id = http_response_json["uri"].split("/")[4];
// NOTE:
// 下記の末尾部分を取得している。
// "uri": "at://did:plc:~(Blueskyのアカウント名を表す文字列)/app.bsky.feed.post/~(1つの投稿を指す文字列)",
// ブラウザでアクセス可能なURLを構築
const posted_message_url = "https://bsky.app/profile/"
+ my_bluesky_username // アカウント名
+ "/post/"
+ posted_message_id // 投稿の識別子
;
// NOTE:
// ここでは,投稿者のBlueskyアカウント名として
// とりあえず自分自身のアカウント名だけを指定できるようにしてある。
// どうしてそうしてあるかというと,
// 引用やリプライの動作テストをする際には
// まず自分自身のアカウント内だけで動作テストしてほしいから。
// そうすれば,他人の投稿に対してテスト目的で引用やリプライが付いてしまって
// まるで動作テストがスパム投稿のようになってしまう…という事態を回避しやすくなるだろう。
// サンプルコードを公開する際には,そういう配慮も必要だと思う。
return posted_message_url;
}
なお hoge() というのがメイン関数ですので,
GASエディタ上では hoge() を選んで実行してみてください。
コードの説明
コード説明(1) メイン処理内の流れ
メイン関数である hoge() の中身としては,
まず bluesky_createSession() で Bluesky API にログインし
認証トークンを得ています。
そして,その認証トークンを使って
bluesky_postOneMessage() を3回呼び出し
3回分のメッセージ投稿を行なっています。
ただし,2回目と3回目のメッセージ投稿の際は
関数のオプションとして,reply_to_url に「リプライ先」を指定 しています。
ここに http 形式の投稿URLを渡せば,
その投稿に対するリプライとしてメッセージ投稿できる,という仕組みです。
「リプライ先」の投稿のURLをhttp形式で渡すためには,
その 直前の投稿の投稿結果 をAPIから取得し
「このURLに投稿成功しました」という情報を持っておく必要があります。
そのために,APIから取得した
投稿結果の情報を http_response_json に格納し,
そのJSONデータの中からURLを抜き出して posted_message_url に代入しています。
「投稿したら,投稿結果のURLを取得して,次回はそこにリプライする。」
この流れを何度も繰り返すことによって
これらの投稿がスレッド化し,リプツリーが生み出される わけですね。
コード説明(2) 投稿ロジック部分
リプライ投稿する際の具体的なAPIとの通信は,投稿ロジックである
bluesky_postOneMessage() の中を読むと分かります。
このロジックの要は,下記の部分で…
// メッセージ投稿用のAPIにアクセスし,HTTPレスポンスを受信する
const http_response_obj = UrlFetchApp.fetch(
"https://bsky.social/xrpc/com.atproto.repo.createRecord",
url_fetch_params
);
UrlFetchApp.fetchの第一引数は,アクセスしたいAPIのURL。
第二引数には,APIとHTTP通信する際のオプション情報 をJSONで渡します。
この第二引数のJSONは,リプライ投稿の場合は
下記のような構造となっています。
まずJSON中のトップレベルの属性では,POSTリクエストであることや,HTTPヘッダ内の認証情報を指定する。
- その中に payload属性 があり,Blueskyユーザ名や,今回の通信の目的がメッセージ投稿であることを記す。
- その中に record属性 があり,今回は具体的にどんな投稿をしたいのかを記載する。投稿本文や投稿日時を指定できる。
- その中に reply属性 があり,今回の投稿はリプライ形式であることを示す。
- その中に root属性 があり,リプライを付けたい対象となる投稿が属しているリプツリーの先頭の投稿の情報を記載する。(AT-uriとcidによって投稿のありかを特定する。)
- 上記のroot属性と並んだ位置に,parent属性 があり,リプライを付けたい対象となる投稿の情報(要は,どこにリプライを付けたいか)を記載する。(AT-uriとcidによって投稿のありかを特定する。)
う~ん入れ子構造ですね~! あんまり見たくない! そして,あまり扱いたくない!
だからこそ,こういう入れ子を隠ぺいするような便利関数が必要なんです。
「JSONの読み書き」というレイヤをいったん,
今回のサンプルコードにあるような専用の関数でラップしてしまえば
その後はもう,JSONの入れ子をじかに取り扱わなくて済みます。
JSONの入れ子じたいは,Web上のAPIを使う限り「逃れられない宿命」ですので。
ネストされたJSONの存在は変えられないとしても,
「それをいかにDSL化するか」(自作の上位レイヤ関数で便利化するか)に 力を割きましょう。
コード説明(3) スレッド情報の取得部分
投稿ロジックの中で,入れ子になったJSONの最下層に近いあたりで
reply 属性がありました。
再掲ですが,reply属性内には下記の2つの情報を記載する必要があります。
- root属性。リプライを付けたい対象となる投稿が属しているリプツリーの先頭の投稿の情報。
- parent属性。リプライを付けたい対象となる投稿の情報。
よく見ると,「あれっ?」と思いませんか。
「parent属性だけでよくない? なんでroot属性も必要なの?」
たとえリプツリーの 末尾 にリプするような場合でも,
なぜか「先頭 の情報」が必要なんですよ。
その「先頭」の情報がないと,リプライっていう処理を受け付けてくれない。
というAPI仕様なんです。
なので,リプライ投稿をしたい場合には
「ある投稿が属するリプツリー の先頭 のURLを取得するような処理」
も必要になってきます。
今回のサンプルコード中では,その処理を行なっているのは
bluesky_getPostDetailInfoByUrl() という関数内です。
その処理をやってる部分のコードを,下記に抜粋しますと・・・
// 着目している投稿をリプライツリーの一部とみなし,
// その属しているツリーの先頭部分(スレッドのルート)の情報を取得する。
if(
thread_info["post"] // post属性の中にルート情報が格納されている場合がある。
&&
thread_info["post"]["record"]
&&
thread_info["post"]["record"]["reply"]
&&
thread_info["post"]["record"]["reply"]["root"] // JSON内の属性をここまでたどれるか。
){
// AT-uri と cid を取得・保管
thread_root_post_at_uri = thread_info["post"]["record"]["reply"]["root"]["uri"];
thread_root_post_cid = thread_info["post"]["record"]["reply"]["root"]["cid"];
}
else
if(
// APIの返却形式によって階層が変わり,下記の形式のケースもある。
thread_info["record"] // post属性が無く,いきなりrecord属性が始まる場合がある。
&&
thread_info["record"]["reply"]
&&
thread_info["record"]["reply"]["root"] // JSON内の属性をここまでたどれるか。
) {
// AT-uri と cid を取得・保管
thread_root_post_at_uri = thread_info["record"]["reply"]["root"]["uri"];
thread_root_post_cid = thread_info["record"]["reply"]["root"]["cid"];
}
あんまり綺麗なコードじゃ~無いと思いますね~。美しくはない。
でも仕方がないんです。
このような 場合分けや,階層を1つずつ慎重にたどる処理 が必要。
そして,上記のコードのおかげで「スレッドの先頭(root)の情報」が確実に取れます。
おかげでリプライ投稿が可能になる。というわけです。
むすびに
いま思ったんですけど,なんか「むすびに」って聞くと,
まるで コンビニで売ってるおむすび みたいな語感ですよね。
「むす」がおむすびで,「びに」がコンビニです。ムスビニ…。
それはいいとして,注意書きしておきますと,
今回のサンプルコードでは投稿ロジック内で
「普通の投稿」と「リプライ形式の投稿」を切り替えられるようにしてあります。
しかし,「引用形式の投稿」をするための処理は,
今回の記事に掲載のコード中からは
あえて削除してありますのでご注意ください。
今回の記事のテーマはリプライであって,引用投稿は要点から外れるからです。
もし引用投稿をしたい場合は,こちらの記事に掲載してある
引用投稿のコードをお使いください。
「引用投稿と,リプライ形式の投稿と
両方に対応した万能関数を作ればよかったんじゃないの?」
と思われるかもしれませんが
それをやっちゃうとコードが複雑になってしまい,
個々の機能のサンプル としては活用しにくくなってしまいます。
個別の機能目的ごとに,ミニマムなコードのサンプルを掲載したい。
という願いからQiita記事を少しずつ書き進めていますので
その点はご了承くださいね。
あ~記事書くの疲れた。
JSONの深い階層を掘り起こす,あまり美しくないコードを読み書きするのも目に毒だのう…。
コンビニでおむすびでも買ってきて,栄養補給するか。。
関連リンク
『(月ごとの進ちょくまとめ)Twitter(X)からBlueskyへbotを移行する作業の履歴・おもな出来事とリンク集』
https://posfie.com/@ouen_suru_tan/p/UjEZA7X
『【作業ログ・パート4】Twitter(X)上で無料でbotが作れなくなったので,かわりにBlueskyでオープンソースのbotツール作りを進めている。現時点で,GASを使って無料で「今年の残り時間bot」や「ラジオ体操bot」などをBluesky上で動かし,自動投稿できている。なかなか大変だけど開発を継続中』
https://posfie.com/@ouen_suru_tan/p/74JZDyD
『facets付与の汎用的なライブラリ。GASでBlueskyにbot投稿する時に使える便利関数。ぴったり千行のサンプルコードで,GoogleスプレッドシートからブルスカAPIに自動ポスト時にハイパーリンク・ハッシュタグ・メンションを自動付与』
https://qiita.com/rwanda_go_tan/items/9ccea753db72c3e08538
『GASのトリガー残り時間を制御・配分・管理する汎用フレームワーク「GTRM (=GAS トリガー・リソース・マネージャー)」。わずか2ステップで利用開始する方法』
https://qiita.com/rwanda_go_tan/items/26071fdcbe7f7a915765
『【2026年9月第2週】コンビニおにぎり・コンビニ手巻寿司の新商品!香り箱の寿司や房総ハーブ鶏など注目おにぎりまとめ』 ←(※私が書いた記事ではなく,今「コンビニおむすび」でググったらたまたま出てきたサイトですが,みんなでコンビニでおむすびを食べましょう!)
https://mognavi.jp/news/matome/gen-newProduct-26545/