Cover Image for Next.jsで作ってるブログにリッチなリンクを貼れるようにした

Next.jsで作ってるブログにリッチなリンクを貼れるようにした

概要

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情報の取得部分はこんな感じで切り出しておく

基本的にやってることとしては

  1. 埋め込みたいページのhtmlを取得する
  2. JSDOMで解析してmetaタグを取得する
  3. 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

参考