個人ブログ(powered by Notion API)にリンクカード機能をつける
個人ブログを持っておりまして、リポジトリも公開しています。
Notionで記事書いて、それをVercelデプロイ時にNotion API経由でレンダリングしてHTMLにする、ということをやっています。
2021年に公開して、そこからちょこちょこ機能追加していったんですが、長年やりたくてできていなかったことが2つありました。
それが下記2つです。
- リンクカード機能
- 目次機能
今時のブログなら、絶対ある機能ですよね。
ようやく2つとも対応できたので、対応内容を書こうと思います。
この記事ではとりあえずリンクカード。次の記事で目次機能について書きます。
リンクカードとは
ここで言っているリンクカードというのは、ブログ記事中に出てきたURLリンクをカード形式で表示する機能のことです。
表示する情報については、OGPというプロトコルが定義されていて、URLの飛び先が対応していると、情報が取得できます。
「OGP対応」というとちょっとややこしいので、リンクカードと呼ぶようにしました。
というのは、
- 自分のサイト自身のOGP情報を返すようにする
- 自分のサイトで表示するURLのOGP情報を取得してカード表示する
これ、どちらもOGP対応かと思いますが、返すのか取得するのかで全然話が変わりますね。
今回は取得する方の話です。
完成したリンクカードがこんな表示です。
必要なこと
一見難しそうに見えますが、表示の流れはシンプルです。
- Notionのブログ記事データベースから、記事データをブロック単位で取得する
- ブロックがURLを表示したい対象だったら、OGP情報を取得に行く
- 取得したOGP情報をもとにカードを生成して表示
NotionのブロックとURL
Notionのブロックにはブロックタイプがあります。
たとえばparagraph、numbered_list_item、bookmarkなどです。
今回URLをリンクカード化するにあたり、どのブロックをどこまでカード化するのかを検討しました。
- paragraph、bookmarkは必須
- link_previewはほぼ使わないけど、対応が簡単なのでやる
- 他のブロックタイプはカードにしない
上記の方針に決めました。
人によってはリストにURLがあるときもカードにして欲しいなどあるかもしれません。
(個人的にはリスト中ならURLのままの方が好み)
厄介なparagraphのデータ構造
bookmarkブロックはurl入ってるだけなんで、扱いが楽なんですが、paragraphはちょっと大変です。
paragraphは、Notionのデフォルトブロックなんですけど、rich_text
が配列で入ってきます。
なんで配列?とはずっと思ってたんですが、今回リンクカード化してようやく理解しました。
1つの文に対して、複数の修飾スタイル・複数のリンクがあり得るので、その場合配列になります。
逆に複数リンクがなくて、修飾スタイルが全文字で一緒なら、1つの要素しかない配列しか返ってきません。
具体例があった方が分かりいいでしょう。
僕のブログで、下記のparagraphブロックがありました。
個人開発に関してもちょっと悩みがあって、人の役に立つものを何かつくりたいと思っているが、特に思いつかないので、結局PoP(Pieces of Paper)とshetommy.com以降何もつくっていない。PoPを地道に改善していくのも粛々とやっていくが、ぼちぼちこのアプリも俺の中で役目を終えつつある感じもして、次に行きたい。が、アイディアがない。
ちょっと長い文ですが、これはブロックとしては1つです。
そしてNotion APIが返してくる rich_text
の配列がこちらです。
(長いので閉じてます)
[rich_text]
[
{
type: 'text',
text: {
content: '個人開発に関してもちょっと悩みがあって、人の役に立つものを何かつくりたいと思っているが、特に思いつかないので、結局',
link: null
},
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default'
},
plain_text: '個人開発に関してもちょっと悩みがあって、人の役に立つものを何かつくりたいと思っているが、特に思いつかないので、結局',
href: null
},
{
type: 'text',
text: { content: 'PoP(Pieces of Paper)', link: [Object] },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default'
},
plain_text: 'PoP(Pieces of Paper)',
href: 'https://github.com/0si43/PiecesOfPaper'
},
{
type: 'text',
text: { content: 'と', link: null },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default'
},
plain_text: 'と',
href: null
},
{
type: 'text',
text: { content: 'shetommy.com', link: [Object] },
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default'
},
plain_text: 'shetommy.com',
href: 'https://github.com/0si43/shetommy.com'
},
{
type: 'text',
text: {
content: '以降何もつくっていない。PoPを地道に改善していくのも粛々とやっていくが、ぼちぼちこのアプリも俺の中で役目を終えつつある感じもして、次に行きたい。が、アイディアがない。',
link: null
},
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default'
},
plain_text: '以降何もつくっていない。PoPを地道に改善していくのも粛々とやっていくが、ぼちぼちこのアプリも俺の中で役目を終えつつある感じもして、次に行きたい。が、アイディアがない。',
href: null
}
]
もしも文中にあるURLをすべてカードにするのであれば、この1ブロックから2つカードを生成しないといけません。
個人的には、この場合リンク付き文字として表示されるのが好みなので、このようなケースはカード化しない方針にしました。
また「URLがある」というのをどこまで判定するか問題もあります。
href要素ついてない平文だけど、「https://」ではじまってる文はカード化したい……みたいな人もいるかもしれません。
ただNotionはURLコピペすると、href要素勝手につき、平文にするのは意図的にやらないとできないので、むしろ対応しない方がいいだろうと思ってやってません。
あとrich_textのデータの中のhrefをみるか、Linkオブジェクト内のurlを見るか問題も細かいですがあります。
これはどっちでもいい?気がしましたが、なんとなくLinkオブジェクト内のurlを見ています。
以上、長々書いてきた条件をif文で書くとこうなりました。
/// OG情報を取得する
if (block.type === 'paragraph'
&& block.paragraph.text.length == 1
&& block.paragraph.text[0].type === 'text'
&& block.paragraph.text[0].text.link?.url
) {
const richText = block.paragraph.text[0] as { type: 'text'; text: { link: { url: string } } }
block.ogpData = await getOgpData(richText.text.link.url)
} else if (block.type === 'bookmark') {
block.ogpData = await getOgpData(block.bookmark.url)
} else if (block.type === 'link_preview') {
block.ogpData = await getOgpData(block.link_preview.url)
}
OGP情報を取得に行く
open-graph-scraperというライブラリでOGP情報を取っています。
CORS制限
OGP情報取得の実装自体はそんなに注意点はありませんが、どこに実装するかは注意した方がいいです。
CORS制限というのがあって、サーバーサイドでOGP情報取得しないと弾かれます。
Access to fetch at 'https://sizu.me/' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
この辺詳しくないのできちんと説明できませんが、僕の理解したところを書くと、
- 異なるドメイン間ではセキュリティのためにリソースを共有できないようになっている(Same-Origin Policy)
- それだと不便なので、CORS: Cross-Origin Resource Sharing という仕組みで可能にした
- サーバーサイドは明示的に許可することで、リソースを別ドメインに利用させることが可能
- OGP情報とかは正当にアクセスすれば取れる
- クライアントサイドの取得を許可すると、不正なスクリプトで情報窃取されるなどのリスクがあるので禁止
なので、open-graph-scraperを使うのはサーバーサイドでやる必要があります。
どこまでがサーバーサイドなのかすらわからなかった
Next.jsの理解が浅くて、自分が書いてるコード、どこまでがサーバーサイドなのかすらわかってませんでした。
このファイルの中の処理全部がSSGなので、デプロイのときに静的に生成されていると誤解していました。
正しくは Post({ title, blocks, tableOfContentsBlocks }: Props)
はクライアントサイドの実行でした。
サーバーサイドで静的に処理するのは getStaticProps
と getStaticPaths
のみでした。
ブロックの型をOGPデータ用に拡張した
上記の理由から、getStaticProps
内でOGP取得をしなければならなくなりました。
当初Notionブロック -> HTML要素に変換してるrenderNotionBlock
の中で実装する想定だったので、ちょっとどう後続処理に渡したらいいか困りました。
最終的に、TypeScriptの型を拡張して、プロパティを追加するというのができることを知って、下記のようにブロックの型を拡張しました。
export type NotionBlockWithChildren = NotionBlock & {
children?: NotionBlockWithChildren[],
ogpData?: OgObject
}
全ブロックについて、このプロパティがnullじゃなかったらカード表示するとしています。
どのブロックをカード化するかの判断は、getStaticProps
内でしているので、
「ogpDataが入っている=カード化すべきブロック」と判断できます。
終わり
以上でリンクカードつける上で苦労した点は書き切りました。
細かいところはリポジトリのプルリク見ていただけたらと思います。
最後に関連するnote、スクラップのリンクを紹介して終わります。
関連note
この開発は生成AIであるClaudeへの質問駆動で進めました。
その様子はnoteで書きました。
開発中のスクラップ
細かいメモは下記で書いています。
(了)
Discussion