GASからBlueSkyに投稿してみる
タスクを先延ばししていたら4月に入っていたYo!
BlueSky用JavaScriptライブラリとしてはatprotoが公開されているが、atprotoをGASに載せるのがしんどいので自作。
ちなみにRSSを取得して、投下するbotは既に動作している。画像は知らない。
RSSから画像を添付するにはRSSのmedia:thumbnailを取得して、画像を自力で貼り付けないと行けない。Xみたいに自動取得はしない。ホームページから取得する場合は更に面倒。<meta property="og:image" >とか、<meta name="twitter:image" >とか、幾つ規格があるんだアレ。
https://docs.bsky.app/docs/advanced-guides/posts サムネイルリンクの作り方は、書いてあるけど実装する気がしない。
既にやっているし。
"embed": {
"$type": "app.bsky.embed.external",
"external": {
"uri": "https://bsky.app",
"title": "Bluesky Social",
"description": "See what's next.",
"thumb": {
"$type": "blob",
"ref": {
"$link": "bafkreiash5eihfku2jg4skhyh5kes7j5d5fd6xxloaytdywcvb3r3zrzhu"
},
"mimeType": "image/png",
"size": 23527
}
}
前準備:
1.BlueSkyの設定画面からアプリパスワードを取得。botに使うパスワードにはアプリパスワードを使うこと(権限の設定ができないけど)
2.GASのスクリプト プロパティに以下を設定。
BLUESKY_USERNAME
BLUESKY_PASSWORD
BLUESKY_SERVER
BLUESKY_SERVERは、通常、https://bsky.social
test()は、テスト用。postBSky(text[, opts])が本体。
opt = {images: [FileID, …]}
textは投稿するtext用、imagesにFileID(配列で最大4)を設定するとイメージが添付できる。
リッチテキストの解析は、atprotoからのコピペ。コピペしているのでライセンスのMIT/Apache-2.0もコピペ。
bot用なのでリンクとハッシュタグの解析はするけどメンションの解析はしない(メンションは、恐らく先にユーザー情報を取得してこないと上手くいかない)
そういえば、サロゲートペアのデバッグしていない。
後、getImageTypeFromBytesは機能していない。unsigned intで実装したらgetBytesがsinged intで取得してくるから面倒になった。getAsでJPEGに変換して投下することにした。
/*
* Copyright ©2024 MITH@mmk Dual MIT/Apache-2.0 License
* https://opensource.org/license/mit
* https://www.apache.org/licenses/LICENSE-2.0
*/
// Google Apps Script
// This is a sample code to post a message to Bluesky.
function getGASProperties() {
// gas properties
return {
identifier: PropertiesService.getScriptProperties().getProperty('BLUESKY_USERNAME'),
password: PropertiesService.getScriptProperties().getProperty('BLUESKY_PASSWORD'),
service: PropertiesService.getScriptProperties().getProperty('BLUESKY_SERVER')
};
}
// regexes for rich text
const MENTION_REGEX = /(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)/g
const URL_REGEX =
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
const TRAILING_PUNCTUATION_REGEX = /\p{P}+$/gu
/**
* `\ufe0f` emoji modifier
* `\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2` zero-width spaces (likely incomplete)
*/
const TAG_REGEX =
/(^|\s)[##]((?!\ufe0f)[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*[^\d\s\p{P}\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]+[^\s\u00AD\u2060\u200A\u200B\u200C\u200D\u20e2]*)?/gu
// from https://github.com/bluesky-social/atproto/blob/main/packages/api/src/rich-text/unicode.ts
class UnicodeString {
getUtf8ByteLength(utf16) {
let utf8 = 0;
for (let i = 0; i < utf16.length; i++) {
let char = utf16.charCodeAt(i);
// is Surrogate Pair
if (char >= 0xd800 && char <= 0xdbff) {
i++;
utf8 += 4;
continue;
}
if (char <= 0x7f) {
utf8 += 1;
} else if (char <= 0x7ff) {
utf8 += 2;
} else if (char <= 0xffff) {
utf8 += 3;
} else {
utf8 += 4;
}
}
return utf8;
}
constructor(utf16) {
this.utf16 = utf16
// utf8 is a Uint8Array
let utf8 = [];
for (let i = 0; i < utf16.length; i++) {
let char = utf16.charCodeAt(i);
// surrogate pair
if (char >= 0xd800 && char <= 0xdbff) {
i++;
const char2 = utf16.charCodeAt(i);
if (char2 >= 0xdc00 && char2 <= 0xdfff) {
char = ((char - 0xd800) << 10) + (char2 - 0xdc00) + 0x10000;
} else {
throw new Error('Invalid UTF-16');
}
}
if (char <= 0x7f) {
utf8.push(char);
} else if (char <= 0x7ff) {
utf8.push(0xc0 | (char >> 6), 0x80 | (char & 0x3f));
} else if (char <= 0xffff) {
utf8.push(
0xe0 | (char >> 12),
0x80 | ((char >> 6) & 0x3f),
0x80 | (char & 0x3f)
);
} else {
utf8.push(
0xf0 | (char >> 18),
0x80 | ((char >> 12) & 0x3f),
0x80 | ((char >> 6) & 0x3f),
0x80 | (char & 0x3f)
);
}
}
}
get length() {
return this.utf8.byteLength
}
get graphemeLength() {
if (!this._graphemeLen) {
this._graphemeLen = graphemeLen(this.utf16)
}
return this._graphemeLen
}
slice(start, end) {
return this.utf8.slice(start, end)
}
utf16IndexToUtf8Index(i) {
return this.getUtf8ByteLength(this.utf16.slice(0, i));
}
toString() {
return this.utf16
}
}
function convertRichTextToFacets(text) {
const string = new UnicodeString(text);
const facets = [];
let match;
/*
while ((match = MENTION_REGEX.exec(text))) {
const start = string.utf16IndexToUtf8Index(match.index);
const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
const username = match[3];
// username to did
const did = this.usernameToDid(username); // no implementation
facets.push({
index: { byteStart: start, byteEnd: end },
features: [{ $type: 'app.bsky.richtext.facet#mention', did: match[3] }]
});
}
*/
while ((match = URL_REGEX.exec(text))) {
const start = string.utf16IndexToUtf8Index(match.index);
const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
facets.push({
index: { byteStart: start, byteEnd: end },
features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[3] }]
});
}
while ((match = TAG_REGEX.exec(text))) {
const start = string.utf16IndexToUtf8Index(match.index);
const end = string.utf16IndexToUtf8Index(match.index + match[0].length);
const tag = match[2] || match[0].replace(TRAILING_PUNCTUATION_REGEX, '');
facets.push({
index: { byteStart: start, byteEnd: end },
features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag}]
});
}
return facets;
}
function loginBluesky(identifier, password, service) {
const endpoint = '/xrpc/com.atproto.server.createSession';
const url = service + endpoint;
const body = JSON.stringify({
identifier: identifier,
password: password
});
const headers = {
'Content-Type': 'application/json'
};
const response = UrlFetchApp.fetch(url, {
method: 'POST',
payload: body,
headers: headers
});
if (response.getResponseCode() !== 200) {
throw new Error(`Login Error ${response.getResponseCode()} ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
function getImageTypeFromBytes(bytes) {
let type = '';
if (bytes.length < 6) {
throw new Error('Is this file is not image. < 6 bytes');
}
if (bytes.length >= 10000000) {
throw new Error(`File size is too large, ${bytes.length} bytes.`);
}
// check format jpeg, png, gif
if (bytes[0] == 0xff && bytes[1] == 0xd8) {
// jpeg
type = 'image/jpeg';
} else if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47 && bytes[4] == 0x0d && bytes[5] == 0x0a && bytes[6] == 0x1a && bytes[7] == 0x0a) {
// png
type = 'image/png';
} else if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 && (bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61) {
// gif
type = 'image/gif';
} else {
throw new Error('Unsupported image format');
}
return type;
}
function convertImageToBytes(imageId) {
const image = DriveApp.getFileById(imageId).getAs('image/jpeg');
const type = 'image.jpeg';
return {bytes: image, type};
}
function uploadImage(session, image, type, service) {
const endpoint = '/xrpc/com.atproto.repo.uploadBlob';
const url = service + endpoint;
const headers = {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': type
};
const response = UrlFetchApp.fetch(url, {
method: 'POST',
payload: image,
headers,
});
if (response.getResponseCode() !== 200) {
throw new Error(`Upload Image Error ${response.getResponseCode()} ${response.getContentText()}`);
}
return JSON.parse(response);
}
function postText(session, text, service, opts = {}) {
const endpoint = '/xrpc/com.atproto.repo.createRecord';
const url = service + endpoint;
const createdAt = new Date().toISOString();
let images = [];
if (opts.images) {
// load images and convert blob
for (let i = 0; i < Math.min(opts.images.length, 4); i++) {
const imageFilename = opts.images[i];
const image = convertImageToBytes(imageFilename);
// upload image
const response = uploadImage(session, image.bytes, image.type, service);
const blob = response;
const alt = `image${i+1}`;
images.push({
alt: alt,
image: blob.blob
});
}
}
// rich text parser is here
// JavaScriptは、UTF-16 送信データはUTF-8なので注意
const facets = convertRichTextToFacets(text);
/* facets[]:
[
{
index: {
byteStart: linkStart,
byteEnd: linkEnd,
},
features: [{ $type: "app.bsky.richtext.facet#link", uri: url }], // isLink
// "#mention", "#link", "#tag
},
]
*/
record = {
$type: 'app.bsky.feed.post',
text,
createdAt,
};
// add facets
if (facets.length > 0) {
record.facets = facets;
}
// add images
if (images.length > 0) {
record.embed = {
$type: 'app.bsky.embed.images',
images
}
}
const response = UrlFetchApp.fetch(url, {
method: 'POST',
payload: JSON.stringify({
repo: session.did,
collection: 'app.bsky.feed.post',
record
}),
accept: 'application/json',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
}
});
if (response.getResponseCode() !== 200) {
throw new Error(`Post Error ${response.getResponseCode()} ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
function postBSky(text, opts = {}) {
const { identifier, password, service } = getGASProperties();
const session = loginBluesky(identifier, password, service);
postText(session, text, service, opts);
}
function test() {
try {
const text = 'GASから投稿してみる #AIイメージ'; // Post Text
const fileimages = ['1fhryCK-0hISgs0CDHTo94APCVxSPnSyX']; // File ID
const opts = {images: fileimages}; // option for with Image
postBSky(text,opts); // post with image
// postBSky(text); // post no image
} catch (error) {
Logger.log(error);
}
}
この記事が気に入ったらサポートをしてみませんか?