Next.jsで作ってるブログにリッチなリンクを貼れるようにした
この記事は最終更新日から2年以上が経過しています。
概要
url 貼って紹介したい時とか、amazonのリンク貼りたい時とかにただのリンクだと面白くないので、twitterのカードUI的なリッチなリンクを貼れるようにする
出来上がったものは以下、こんな感じでリッチなリンクになる(なぜAmazon?は後述)
手順
- OGP情報を取得するAPIを作る
- 取得した情報でリッチなリンクを表示する
詳細
OGP情報を取得するAPIを作る
apiのエンドポイントをこんな感じで作っておく
// src/pages/api/ogp.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { getOgp } from '../../lib/getOgp'
const ogp = async (req: NextApiRequest, res: NextApiResponse) => {
const url = req.query.url
if (!url || Array.isArray(url)) return res.status(400)
const meta = await getOgp(url)
res.status(200).json(meta)
}
export default ogp
OGP情報の取得部分はこんな感じで切り出しておく
基本的にやってることとしては
- 埋め込みたいページのhtmlを取得する
- JSDOMで解析してmetaタグを取得する
- OGP用のタグから情報を取得する
毎回parseすると時間かかるし埋め込みたい先に負荷がかかってしまうので、キャッシュも用意しておく
// src/lib/getOgp.ts
import axios from 'axios'
import { JSDOM } from 'jsdom'
import { getAmazonImageUrl, getAmazonShortUrl } from './amazon'
import { readCache, writeCache } from './cache'
export type OgpMeta = {
title: string
description: string
image: string
url: string
}
const trimTitle = (title: string) => title.trim()
const getImageUrl = (imageUrl: string, url: string) => {
if (imageUrl.charAt(0) !== '/') return imageUrl
const parsed = new URL(url)
return `${parsed.protocol}//${parsed.hostname}${imageUrl}`
}
export const getOgp = async (url: string): Promise<OgpMeta> => {
const isAmazon = url.includes('https://www.amazon.co.jp/')
const encodedUri = encodeURI(isAmazon ? getAmazonShortUrl(url) ?? url : url)
const cache = await readCache(encodedUri)
if (cache) return cache
console.log('cache miss!')
const headers = { 'User-Agent': 'bot' }
const res = await axios.get(encodedUri, { headers: headers })
const html = res.data
const dom = new JSDOM(html)
const meta = isAmazon
? dom.window.document.body.querySelectorAll('meta')
: dom.window.document.head.querySelectorAll('meta')
const metaTags = Array.from(meta.values())
const metaData = Object.fromEntries(
metaTags
.filter(element => element.name !== '')
.map(element => [element.name, element.content])
)
const propertyData = Object.fromEntries(
metaTags
.filter(element => element.hasAttribute('property'))
.map(element => [
element.getAttribute('property')?.trim(),
element.content
])
)
const mergedData = { ...metaData, ...propertyData }
const ogpMeta: OgpMeta = {
title: trimTitle(mergedData['og:title'] ?? mergedData['title']),
description: mergedData['og:description'] ?? mergedData['description'],
image: isAmazon
? getAmazonImageUrl(encodedUri) ?? ''
: getImageUrl(mergedData['og:image'], encodedUri),
url: encodedUri
}
await writeCache(ogpMeta)
return ogpMeta
}
amazonnだけはOGPが設定されていないので、titleとdescriptionから情報を取得する。 画像はASINを元に生成する (ついでにリンクにアフェリエイト用のtagも埋め込んでおく)
// src/lib/amazon.ts
import { AMAZON_AFFILIATE_TAG } from './constants'
const imageSizes = [
'THUMBZZZ', // サムネ 75 × 75
'TZZZZZZZ', // 小 110 × 110
'MZZZZZZZ', // 中 160 × 160
'LZZZZZZZ' // 大 500 × 500
] as const
export type ImageSize = typeof imageSizes
const asinRegex = /[^0-9A-Z]([0-9A-Z]{10})([^0-9A-Z]|$)/
export const getASIN = (url: string) => {
const asin = (url.match(asinRegex) ?? [])[1]
if (asin === null || asin === undefined || asin === '') return null
return asin
}
export const getAmazonShortUrl = (url: string) => {
const asin = getASIN(url)
if (asin === null) return null
return `https://www.amazon.co.jp/dp/${asin}?tag=${AMAZON_AFFILIATE_TAG}`
}
export const getAmazonImageUrl = (url: string, size?: ImageSize) => {
const asin = getASIN(url)
if (asin === null) return null
return `https://images-na.ssl-images-amazon.com/images/P/${asin}.09.${
size ?? 'LZZZZZZZ'
}.jpg`
}
取得した情報でリッチなリンクを表示する
上記のAPIを呼び出して取得した情報を流し込んで、リンクとして表示するコンポーネントを作る(ブラウザ側でのロードに時間がかかるので、SkeletonLoaderも用意しておく)
// src/components/domain/embed/rich-link.tsx
import Image from 'next/image'
import Link from 'next/link'
import { VFC } from 'react'
import { customLoader } from '../../../lib/image-loader'
type Props = {
title: string
description: string
image: string
url: string
}
const RichLink: VFC<Props> = ({ url, title, description, image }) => {
const parsedUrl = new URL(url)
return (
<div className="not-prose my-2">
<Link href={url}>
<a className="">
<div className="flex w-full border border-primary-light items-center divide-x divide-primary-light justify-between hover:opacity-50">
<div className="text-surface flex flex-col gap-1 px-2">
<span className="font-bold">{title}</span>
<span className="text-xs text-background-light line-clamp-1">
{description}
</span>
<span className="text-sm">{parsedUrl.hostname}</span>
</div>
<div className="h-full p-2">
<figure className="relative w-[120px] h-[120px]">
<Image
loader={customLoader}
src={image}
alt={`Cover Image for ${title}`}
layout="fill"
objectFit="contain"
/>
</figure>
</div>
</div>
</a>
</Link>
</div>
)
}
const SkeletonLoader: VFC = () => {
return (
<div className="not-prose my-2">
<div className="flex w-full border border-primary-light items-center divide-x divide-primary-light justify-between">
<div className="flex-1 grid grid-cols-3 gap-2 animate-pulse px-2">
<div className="col-span-2 h-6 bg-slate-700 rounded" />
<div className="col-span-3 h-4 bg-slate-700 rounded" />
<div className="col-span-1 h-4 bg-slate-700 rounded" />
</div>
<div className="h-full p-2 animate-pulse">
<div className="h-[120px] w-[120px] bg-slate-700 rounded" />
</div>
</div>
</div>
)
}
type ConnectProps =
| {
meta?: undefined
isLoading: true
}
| {
meta: Props
isLoading?: undefined
}
const Connect: VFC<ConnectProps> = ({ meta, isLoading }) => {
if (isLoading) return <SkeletonLoader />
return <RichLink {...meta} />
}
export default Connect