Open1

Go言語を使ってBlueskyにリンクカードとハッシュタグの組み込んだ投稿をする方法

シロシロ

Blueskyの公式Goクライアント

https://github.com/bluesky-social/indigo

やりたいこと

  • APIを使用してBlueskyに投稿したい
  • 投稿するテキストにURLが含まれていた場合は、リンク化してリンクカードを埋め込みたい
    • URLが含まれる投稿テキストをただ投稿するだけだとURLはただのテキストで投稿される。またリンク先のリンクカードも作成されない。
  • 投稿するテキストにハッシュタグ(#で始まるタグ)が含まれている場合は、ハッシュタグ化したい
    • ハッシュタグが含まれる投稿テキストをただ投稿するだけだとハッシュタグはリンク化されず、ただのテキストで投稿される。

実コード

package main

import (
	"bufio"
	"bytes"
	"context"
	"io"
	"log"
	"net/http"
	"regexp"
	"strings"
	"time"

	"github.com/PuerkitoBio/goquery"
	"github.com/bluesky-social/indigo/api/atproto"
	comatproto "github.com/bluesky-social/indigo/api/atproto"
	"github.com/bluesky-social/indigo/api/bsky"
	lexutil "github.com/bluesky-social/indigo/lex/util"
	"github.com/bluesky-social/indigo/xrpc"
	encoding "github.com/mattn/go-encoding"
	"golang.org/x/net/html/charset"
)

// テキストから抽出されたタグやリンクを表す構造体です。
type entry struct {
	start int64
	end   int64
	text  string
}

// タグとURLを識別するための正規表現パターン
var (
	tagRe = regexp.MustCompile(`\B#\S+`)
	urlRe = regexp.MustCompile(`https?://\S+`)
)

func main() {
	// Bluesky APIにアクセスするためのクライアントを初期化します。
	cli := &xrpc.Client{
		Host: "https://bsky.social",
	}

	// セッションを作成するための入力データを準備します。
	input := &atproto.ServerCreateSession_Input{
		Identifier: "[your-bluesky-handle]", // Blueskyのハンドル名(xxxxx.bsky.social)
		Password:   "[your-password]",       // Blueskyのパスワード
	}
	// セッション作成のリクエストを送信し、結果を受け取ります。
	output, err := atproto.ServerCreateSession(context.TODO(), cli, input)
	if err != nil {
		log.Fatal(err)
	}
	// 認証情報をクライアントに設定します。
	cli.Auth = &xrpc.AuthInfo{
		AccessJwt:  output.AccessJwt,
		RefreshJwt: output.RefreshJwt,
		Handle:     output.Handle,
		Did:        output.Did,
	}

	// ここで投稿データを作成します
	text := "" // URLあり、ハッシュタグありの投稿テキストを用意する
	post := &bsky.FeedPost{
		Text:      text, // 投稿テキスト
		CreatedAt: time.Now().Format(time.RFC3339), // 投稿日時
		Langs:     []string{"ja"}, // 言語設定
		Embed:     &bsky.FeedPost_Embed{}, // 埋め込みデータ
	}

	// テキストからタグを抽出し、投稿データに追加します。
	for _, entry := range extractTagsBytes(text) {
		post.Facets = append(post.Facets, &bsky.RichtextFacet{
			Features: []*bsky.RichtextFacet_Features_Elem{
				{
					RichtextFacet_Tag: &bsky.RichtextFacet_Tag{
						Tag: entry.text,
					},
				},
			},
			Index: &bsky.RichtextFacet_ByteSlice{
				ByteStart: entry.start,
				ByteEnd:   entry.end,
			},
		})
	}

	// テキストからリンクを抽出し、投稿データに追加します。
	entryies := extractLinksBytes(text)
	if len(entryies) > 0 {
		for _, entry := range entryies {
			post.Facets = append(post.Facets, &bsky.RichtextFacet{
				Features: []*bsky.RichtextFacet_Features_Elem{
					{
						RichtextFacet_Link: &bsky.RichtextFacet_Link{
							Uri: entry.text,
						},
					},
				},
				Index: &bsky.RichtextFacet_ByteSlice{
					ByteStart: entry.start,
					ByteEnd:   entry.end,
				},
			})
		}
		// 最初のリンクを投稿に埋め込むための追加処理を行います。
		addLink(cli, post, entryies[0].text)
	}

	input := &atproto.RepoCreateRecord_Input{
		Collection: "app.bsky.feed.post",
		Repo:       cli.Auth.Did,
		Record: &lexutil.LexiconTypeDecoder{
			Val: post,
		},
	}
	// 投稿データをBlueskyに送信します。
	_, err := atproto.RepoCreateRecord(ctx, cli, input)
	if err != nil {
		log.Println("Error posting to bluesky: ", err)
	}
}

// 投稿テキストからタグを抽出する関数です。
func extractTagsBytes(text string) []entry {
	var result []entry
	matches := tagRe.FindAllStringSubmatchIndex(text, -1)
	for _, m := range matches {
		result = append(result, entry{
			text:  strings.TrimPrefix(text[m[0]:m[1]], "#"),
			start: int64(len(text[0:m[0]])),
			end:   int64(len(text[0:m[1]]))},
		)
	}
	return result
}

// 投稿テキストからリンクを抽出する関数です。
func extractLinksBytes(text string) []entry {
	var result []entry
	matches := urlRe.FindAllStringSubmatchIndex(text, -1)
	for _, m := range matches {
		result = append(result, entry{
			text:  text[m[0]:m[1]],
			start: int64(len(text[0:m[0]])),
			end:   int64(len(text[0:m[1]]))},
		)
	}
	return result
}

// 投稿データに外部リンクの詳細(タイトル、説明、サムネイル画像)を追加する関数です。
func addLink(xrpcc *xrpc.Client, post *bsky.FeedPost, link string) {
	res, _ := http.Get(link)
	if res != nil {
		defer res.Body.Close()

		br := bufio.NewReader(res.Body)
		var reader io.Reader = br

		data, err2 := br.Peek(1024)
		if err2 == nil {
			enc, name, _ := charset.DetermineEncoding(data, res.Header.Get("content-type"))
			if enc != nil {
				reader = enc.NewDecoder().Reader(br)
			} else if len(name) > 0 {
				enc := encoding.GetEncoding(name)
				if enc != nil {
					reader = enc.NewDecoder().Reader(br)
				}
			}
		}

		var title string
		var description string
		var imgURL string
		doc, err := goquery.NewDocumentFromReader(reader)
		if err == nil {
			title = doc.Find(`title`).Text()
			description, _ = doc.Find(`meta[property="description"]`).Attr("content")
			imgURL, _ = doc.Find(`meta[property="og:image"]`).Attr("content")
			if title == "" {
				title, _ = doc.Find(`meta[property="og:title"]`).Attr("content")
				if title == "" {
					title = link
				}
			}
			if description == "" {
				description, _ = doc.Find(`meta[property="og:description"]`).Attr("content")
				if description == "" {
					description = link
				}
			}
			post.Embed.EmbedExternal = &bsky.EmbedExternal{
				External: &bsky.EmbedExternal_External{
					Description: description,
					Title:       title,
					Uri:         link,
				},
			}
		} else {
			post.Embed.EmbedExternal = &bsky.EmbedExternal{
				External: &bsky.EmbedExternal_External{
					Uri: link,
				},
			}
		}
		if imgURL != "" && post.Embed.EmbedExternal != nil {
			resp, err := http.Get(imgURL)
			if err == nil && resp.StatusCode == http.StatusOK {
				defer resp.Body.Close()
				b, err := io.ReadAll(resp.Body)
				if err == nil {
					resp, err := comatproto.RepoUploadBlob(context.TODO(), xrpcc, bytes.NewReader(b))
					if err == nil {
						post.Embed.EmbedExternal.External.Thumb = &lexutil.LexBlob{
							Ref:      resp.Blob.Ref,
							MimeType: http.DetectContentType(b),
							Size:     resp.Blob.Size,
						}
					}
				}
			}
		}
	}
}