Githubにツイッターのプロフィールを表示できる画像を生成するAPIを作る


巷ではGithubのプロフィールをデコるのが流行っています。
具体的には[GitHub Readme Stats を利用してGitHubプロフィールをカッコよくする](https://qiita.com/zizi4n5/items/f8076cb25bbf64a9bc1c)のような記事を読んでもらえばいいんですが、ここで紹介されている物以外を載せたい場合があると思います。

そこで、今回は試しにTwitterのプロフィール情報を取得し画像にして返す[twitter-profile-card](https://github.com/ivgtr/twitter-profile-card)というものを書きました。
`Vercel`にデプロイされており、`URL`にidのクエリを付けて叩くとTwitterのプロフィールの画像が返ってきます。
Twitterの情報を取得する手順で他のデータを取得すれば応用が利くはずです。

記事では書いたCSSの詳細は省いていますが、この様な感じで表示されます。(画像はpngにした物ですが、リンク先は生成された物になっています。)
詳しくはリポジトリを見て下さい。
<a href="https://twitter-profile-card.ivgtr.vercel.app/api?id=twitter">
   <img width="300" alt="twitter-profile-card-default" src="https://user-images.githubusercontent.com/43836584/105163007-b19b6b80-5b56-11eb-857e-e8b9072a42b6.png">
</a>

## やりたいこと

1. Twitter APIを叩いて、プロフィールを取得
2. HTMLElementにデータを埋め込んでスタイリング[^1]
3. `Puppeteer`でHTMLElementのスクリーンショットを撮る
4. svgの配下のimageタグにスクリーンショットを埋め込んだレスポンスを返す
5. これらをデプロイ

## 構成

```
.
├── README.md
├── package.json
├── vercel.json
├── api
│   └── index.ts
├── src
│   ├── createCard.ts
│   ├── createElement.tsx
│   └── getTwitterData.ts
└── tsconfig.json
```
- `/api`がエンドポイントになります

## データの取得
好きなように取得してください。
今回はTwitterのプロフィール情報を取得し返す、と言う事で適当に取得します。

```ts:src/getTwitterData.ts
export function getTwitterData({ id }) => {
  const headers = {
    Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN}`
  }

  const params = {
    screen_name: id
  }

  const userShowEndoPoint = 'https://api.twitter.com/1.1/users/show.json'

  return new Promise((resolve, reject) => {
    axios
      .get(userShowEndoPoint, { headers, params })
      .then((response) => resolve(response.data))
      .catch((err) => {
        return reject(err.response)
      })
  })
}
```

## データをスタイリングし、Puppeteerでスクリーンショットを撮る
`Vercel`は`AWS Lambda`上で動いてる為、デプロイパッケージのサイズに制限があります。
`Puppeteer`に同梱さているChrome単体パッケージが単体で250MBもあるので、そのまま`Vercel`にデプロイしてしまうと、サイズ上限に引っかかってしまいます。

そこで、サイズ上限を回避するために`chrome-aws-lambda`と`puppeteer-core`を利用します。[^2]
この２つはバージョンを合わせる必要があるので注意が必要です。

```ts:src/createCard.ts
import chrome from 'chrome-aws-lambda'
import puppeteer from 'puppeteer-core'
```

また、ローカル環境では`chrome-aws-lambda`が働いてくれないので、サーバー上で動いているかを条件分けし、ローカルでは自分のPCに入っているChromeを使うようにします。

```ts:src/createCard.ts

  const browser = await puppeteer.launch(
    process.env.AWS_REGION
      ? {
          args: chrome.args,
          executablePath: await chrome.executablePath,
          headless: chrome.headless
        }
      : {
          args: [],
          executablePath:
            process.platform === 'win32'
              ? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
              : process.platform === 'linux'
              ? '/usr/bin/google-chrome'
              : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
        }
  )
```

データを表示する為に、`html`を書きます。
スタイリングにインテリセンスが効かないと辛いので、`.tsx | .jsx`拡張子のファイルを作り、`JSX.Element`を返す関数を書きます。
全てを書くと長いので、ここではヘッダーを表示する部分だけ書きます。

```tsx:src/createElement.tsx

import * as React from 'react'

export function createElement(tweetData) {

  const header = {
    height: '33%',
    width: '100%',
    overflow: 'hidden'
  }

  const headerImage = {
    height: '100%',
    width: '100%',
    objectFit: 'cover'
  }
  
  return (
    <div style={header}>
        <img
          src={tweetData.profile_banner_url}
          alt="header image"
          height="100px"
          width="300px"
          style={headerImage}
        />
    </div>
  )
}
```
この様に`inline-style`でスタイリングしていきます。

この`JSX.Element`を`react-dom/server`の`renderToString`を使う事でstring型に変換でき、それを`Puppeteer`で読み込み、スクリーンショットを撮ります。

```ts:src/createCard.ts
import { renderToString } from 'react-dom/server'
import { createElement } from './createElement'
```

```ts:src/createCard.ts

const element = createElement(tweetData)

const page = await browser.newPage()
await page.setContent(
  `<html>
      <head>
        <style>
          body {
            width: "${width}";
            height: "${height}";
          }
        </style>
      </head>
      <body>${renderToString(element)}</body>
    </html>
  `
)

const image = await page.$('body')
const buffer = await image.screenshot({ encoding: 'base64' })
```

### 補足
日本語のフォントが`Puppeteer`に存在しないので文字化けしてしない為、elementに日本語が混じっていると文字化けしてしまうのでgooglefontsなどのcdnからfontを読み込む様にします。

```ts:src/createCard.ts
await chrome.font(
  'https://rawcdn.githack.com/googlefonts/noto-cjk/be6c059ac1587e556e2412b27f5155c8eb3ddbe6/NotoSansCJKjp-Regular.otf'
)
await chrome.font(
  'https://rawcdn.githack.com/googlefonts/noto-fonts/ea9154f9a0947972baa772bc6744f1ec50007575/hinted/NotoSans/NotoSans-Regular.ttf'
)
```

## 撮ったスクリーンショットをクライアントに返す
上述しましたが、クライアント側のエンドポイントは`/api`になります。
(いくつかのレポジトリでこうなってたので参考にしました。)
`./api/index.ts`で、リクエストを受けレスポンスを返す処理を行います。

```ts
export default async (req,res) => {
  const { id } = req.query
  res.send(id)
}
```
`req.query`に叩いたURLのqueryが入ります。
(例えば`/api?id=Twitter`とすると、req.query.idにTwitterが入ります。)
`res.send`でクライアントにレシポンスを返すことができ、`res.setHeader`でHeaderを設定します。

先ほど撮ったスクリーンショットをsvgに埋め込み、クライアントに返します。

```ts:src/createCard.ts
return `
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="${width}"
    height="${height}"
    viewport="0 0 ${width} ${height}"
    fill="none"
  >
    <image href="data:image/jpeg;base64,${buffer}" x="0" y="0" width="100%" height="100%"/>
  </svg>
`
```

```ts:api/index.ts
import { createCard } from '../src/createCard'
import { getTwitterData } from '../src/getTwitterData'

export default async (req,res) => {
  const result = await getTwitterData(req.query) // Twitterのデータ取得
  const svgImage = await createCard()  // svg画像のHTMLelementを取得

  res.setHeader('Content-Type', 'image/svg+xml') // svgを指定
  res.setHeader('Cache-Control', `public, max-age=${60 * 60 * 12}`) // データの変化があまりないのでキャッシュを１２時間に
  res.send(svgImage) // データを返す
}
```
これでクライアントにsvg画像を返すことができました。

## 確認、デプロイ
`vercel.json`を設定し、`Vercel dev`でローカルサーバーを起動できます。
`http://localhost:3000/api?id=Twitter`で画像が表示されれば完成です。

あとは`vercel`コマンドでデプロイできます、簡単ですね。

## まとめ
フォロワーが居ないアカウントでTwitterのプロフィールを表示させると悲しくなるので注意が必要

## 参考
- [Vercel上でPuppeteerを動かす](https://qiita.com/n0bisuke/items/d3b45cb33351e68d9c86) - @n0bisuke 
- [Vercelのイベントサイトでの実装](https://github.com/vercel/virtual-event-starter-kit/blob/main/pages/api/ticket-images/%5Busername%5D.tsx)


[^1]: スタイリングにインテリセンスが効かないと辛いので（１敗）、スタイリングは`jsx`で書いていき、`ReactDOMServer.renderToString()`を使って変換します
[^2]: `chrome-aws-lambda`の配下に`Puppeteer`が存在し、それを利用すれば一見動きそう何ですが、`puppeteer-core`が依存関係にあり、両方必要です