🌈

自分のはてなブックマークをBlueskyに自動投稿させる

2023/06/21に公開

Twitterがいつまで安心して使えるか分からない状況が依然として続いているので、最近はTwitter以外のSNSも並行して試し始めました。
自分のTwitterの使い方の半分ぐらいは面白かった技術記事をシェアすることなのでまずはこれをマルチポストするところから始めたいと思ったのですが、一度Twitterへ投稿した後に毎回コピペして他のSNSに投稿し直すのは面倒なのであまり長続きしなさそうでした。

そんな中、azuさんがTwitterとBlueskyにマルチポストさせるツールを作成しているというのを見ました。

https://efcl.info/2023/06/18/bluesky-post/

その記事と少しだけリポジトリの中身も拝見したのですが、Electron製のアプリということで残念ながら自分の用途にはちょっと合わなさそうでした。自分の場合は自宅内でもPC以外にiPadから投稿することがあるのと、出先ではAndroidから投稿しているのでElectron製でPCオンリーだと自分のスタイルを変える必要が出てしまうためです。

ここ数日何か良い方法ないかなーと考えていたのですが、今日の朝に朝日で目覚ましよりも早く起こされて寝ぼけているときに、ふとはてなブックマークをデータソースとしてここに保存したものを自動的に各SNSにマルチポストする仕組みを作れば自分がやりたいことは8割ぐらいは実現できるのではないかと思いつきました。自分は今でも技術記事ははてなブックマークに保存することが多く、マルチポストするのに懸念と思われるTwitter(懸念については後述)に関してもはてなブックマーク側が一応対応[1]しているためです。

実ははてなブックマークは特定のユーザーのブックマークをRSSで取得できるので、これをデータソースにしてZapierやIFTTTを使えば簡単に実現できるのではと思って調べてみたのですが、両方ともRSSを入力にできるものの投稿先はTwitter[2]ぐらいしかなく、Blueskyにはまだ対応していないようでした。

というわけで仕方がないので自作してみました。RSSを読み取り、未投稿のエントリだけにフィルターしてBlueskyに投稿するだけです。Denoの学習中なので勉強がてらDenoで書いてみました。

//
//  main.ts
//
//  Created by Kesin11 on 2023/06/20.
//  Copyright © 2023 Kesin11. Licensed under MIT.
//

import { excludePosted, fetchHatenaBookmarks } from "./hatenab.ts";
import { Bluesky } from "./bluesky.ts";

type metadata = { lastPosted: string };

let lastPosted: Date | undefined = undefined;
try {
  const metaFile = await Deno.readTextFile("./metadata.json");
  const metadata = JSON.parse(metaFile) as metadata;
  lastPosted = (metadata.lastPosted)
    ? new Date(metadata.lastPosted)
    : undefined;
} catch (_error) {}

const bookmarks = await fetchHatenaBookmarks();
const filterdBookmarks = excludePosted(bookmarks, lastPosted);
console.dir(filterdBookmarks);

const bsky = await Bluesky.init();
await bsky.postHatenaBookmarks(filterdBookmarks);

// excludePostedは昇順ソートされている前提
const newLastPosted = filterdBookmarks.at(-1)?.date;
if (newLastPosted) {
  await Deno.writeTextFile(
    "./metadata.json",
    JSON.stringify({ lastPosted: newLastPosted.toISOString() } as metadata),
  );
}
//
//  hatenab.ts
//
//  Created by Kesin11 on 2023/06/20.
//  Copyright © 2023 Kesin11. Licensed under MIT.
//
import Parser from "npm:rss-parser";

export type RssBookmark = {
  date: Date;
  isoDate: string;
  title: string;
  link: string;
  content: string;
};
export async function fetchHatenaBookmarks(): Promise<RssBookmark[]> {
  const parser: Parser = new Parser({});
  const feed = await parser.parseURL(
    "https://b.hatena.ne.jp/Kesin/bookmark.rss",
  );
  return feed.items.map((entry) => {
    return {
      date: new Date(entry.date),
      isoDate: entry.isoDate!,
      title: entry.title!,
      link: entry.link!,
      content: entry.content!,
    };
  });
}

export function excludePosted(
  bookmarks: RssBookmark[],
  lastPosted: Date | undefined,
): RssBookmark[] {
  if (bookmarks.length === 0) return [];
  // 前回の最後のエントリの時間がなければ最新のエントリだけを返す
  if (lastPosted === undefined) {
    return [bookmarks.at(0)!];
  }

  return bookmarks
    .toSorted((a, b) => a.date.getTime() - b.date.getTime())
    .filter((entry) => entry.date.getTime() > lastPosted.getTime());
}
//
//  hatenab.ts
//
//  Created by Kesin11 on 2023/06/20.
//  Copyright © 2023 Kesin11. Licensed under MIT.
//

// NOTE: なぜかBskyAgentを直接importできないとDenoがエラーを出した。commonjsでpublishされているから?
// import { BskyAgent, RichText } from "npm:@atproto/api@0.3.12";
import AtprotoAPI from "npm:@atproto/api@0.3.12";
const { BskyAgent, RichText } = AtprotoAPI;
import { RssBookmark } from "./hatenab.ts";

export class Bluesky {
  constructor(public agent: AtprotoAPI.BskyAgent) {}
  static async init() {
    const agent = new BskyAgent({ service: "https://bsky.social" });
    const identifier = Deno.env.get("BLUESKY_IDENTIFIER");
    const password = Deno.env.get("BLUESKY_PASSWORD");
    if (!identifier || !password) {
      throw new Error("BLUESKY_IDENTIFIER or BLUESKY_PASSWORD is not set");
    }
    await agent.login({ identifier: identifier, password: password });
    return new Bluesky(agent);
  }

  private async post(text: string) {
    const rt = new RichText({ text });
    await rt.detectFacets(this.agent);
    await this.agent.post({
      $type: "app.bsky.feed.post",
      text: rt.text,
      facets: rt.facets,
      createdAt: new Date().toISOString(),
    });
  }

  async postHatenaBookmarks(bookmarks: RssBookmark[]) {
    for await (const bookmark of bookmarks) {
      const text = (bookmark.content !== "")
        ? `${bookmark.content} ${bookmark.link}`
        : `"${bookmark.title}" ${bookmark.link}`;
      await this.post(text);
    }
  }
}

Blueskyへの投稿部分は以下の記事を参考にしました。BlueskyのAPIを使ったのは今回が初めてでしたが、今回みたいに投稿するだけならすごい簡単。

https://zenn.dev/kawarimidoll/articles/42efe3f1e59c13
https://efcl.info/2023/06/18/bluesky-post/

azuさんの記事でも触れられていますが、Twitterやzennみたいにリッチな表示をさせるためにはもう少し複雑なコードを自前で書く必要があるようです。今日の思いつきで作ったプロトタイプなのでこれは一旦保留。

https://zenn.dev/ryo_kawamata/articles/8d1966f6bb0a82

後はこのスクリプトをどこかでcronで定期的に実行すればはてなブックマークに保存した記事が自動的にBlueskyに投稿されます。投稿後に最新エントリの時間をmetadata.jsonに保存しておき、2回目以降の実行ではその時間以降のエントリだけにフィルターすることで簡易的ですが重複投稿を防いでいます。ローカル環境でcron実行する場合は問題ないですが、GitHub Actionsみたいな毎回クリーンな環境で実行される場合はmetadata.jsonの永続化が必要です。雑にやるなら actions/cache で適当なキーで保存しておく方法があると思います。

脚注
  1. Twitterの騒動で一度は連携不可能になってしまったのですが、投稿後にTwitterへ飛ばしてくれる機能追加によって再び実用的に戻りました https://bookmark.hatenastaff.com/entry/2023/05/16/150518 ↩︎

  2. ちなみにTwitterへの投稿も今や両方とも無料では不可能で有料プランが必要です。 ↩︎

Discussion