[2021 W37] ソーシャルメディアプレビューを追加


# 2021 W37

先日[キドク](https://kidok.app?u=qiita)を公開した！（[キドク実装方法 | Qiita記事](https://qiita.com/kidoku/items/73f1cfcb27f2d05819c4)）

徐々にUIの改善が行われた。今週の技術的なハイライトはソーシャルメディアのプレビューができる様になったことだ。

### TL;DR

[create-react-app (CRA)](https://github.com/facebook/create-react-app)でウェブアプリを作ったが、ソーシャルメディアで共有すると、上手く表示されない。CRAにデフォルトで含まれていた[`serve -s build`](https://create-react-app.dev/docs/deployment/#static-server)を使っていた。これを少しの工夫でプレビューができた。

| SNS | Before | After |
|--|--|--|
| Facebook | ![Screen Shot 2021-09-19 at 12.57.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/8f3ff1e3-2748-f29b-96fe-f600b0d67f79.png) | ![Screen Shot 2021-09-19 at 12.58.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/4acb72b4-9ea7-a1f1-14b6-a2d97f0eb934.png) |
| LINE |  ![Screen Shot 2021-09-19 at 12.57.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/d16f969e-10d3-0eda-214b-9e4ff57e3fff.png) | ![Screen Shot 2021-09-19 at 12.58.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/b0c6bd69-a62d-04f1-d112-788accd7f9fd.png) |


### ソーシャルメディアのプレビューはどんな構造なのか

SNSで表示されるリンクのプレビューは以下の様な実装だ。

1. URLをユーザーが入力する。
1. SNS側のサーバーボットがURLをアクセスする。
1. JavaScriptが無効化の状態で、表示されたHTMLのタグを読み込む。



なのでSPAのようなウェブアプリは`<noscript></noscript>`のコンテンツのみが表示され頑張って書いたJSコードが実行されない。[Twitter](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started)によればbody内のコンテンツよりも`<meta>`タグの内容だけでプレビューができる。この資料をもとに、他のSNSも[`Open Graph Protocol`](https://ogp.me/)に従っていると想定した。

### SSRは必要なのか

解決するには`<meta>`タグを追加できれば方法は何でも良い。だが、動的コンテンツ（`/post/:postId`)に対応するにはサーバーがパスを処理して`<meta>`タグを返す必要がある。逆に言えば`<meta>`タグを返せばページは空で問題ない。Next.jsなどでReactコンポーネントをrenderした後に`hydrate()`を使ったサーバーとフロントの連携が必要なくなるので、SSRは必要だが最後の`R = render`の部分はいらない。

### クローラーの検知はどう行うのか

簡単にいうと`User-Agent`ヘッダーで検知が可能だ。以下の公式資料をもとに性能が良いと思ったモジュールが[`isbot`](https://github.com/omrilotan/isbot)だ。`isbot`は性能が良く[デモ](https://isbot.js.org/)で確認できる。ちなみに`User-Agent`が[空の場合もあるが](https://stackoverflow.com/a/24274846/1615594)、そこまで気にすることでもないみたい。

[Facebookクローラー公式資料](https://developers.facebook.com/docs/sharing/webmasters/crawler/)
[Twitterクローラー公式資料](https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started#crawling)
[LINEクローラー公式資料](https://help2.line.me/linesearchbot/web/?contentId=50006055&lang=en)

```

export const detectBot = async (
    req: express.Request,
    res: express.Response,
    next: express.NextFunction,
) => {
  const bot = isbot(req.headers['user-agent'])
  if (bot === true) {
  }
}
```

### `serve`からどの様に移行したのか

[`CRA`のドキュメント通り](https://create-react-app.dev/docs/deployment/#other-solutions)カスタマイズする方法が書かれてたのでこれを元に、`express.static`が返される前に`middleware`でクローラーの検知を行う。今考えれば`app.get('/*')`の直前でも良かった。

```

const app: express.Express = express();

app.use(detectBot)

app.use(express.static(path.join(__dirname, './../build')));

app.get('/*', get);

```

### URLパスはどの様にパースしているのか

URLパスは[`url-pattern`](https://www.npmjs.com/package/url-pattern)を使いRegexマッチする。クライアント側の`Route`のフォーマットが変わった場合、こっちに反映されないのが一つのリスクだ。今後揃えれる様にすればいいと思う。

また、`app.use(パス, middleware)`でパスごとに分けても良いが、複数のパスがあると`isbot`が何回も実行される。重い処理ではないのでどちらでも良いが。

```

const postPattern = new UrlPattern('/thread/:threadId/:name/post/:postId');
const threadPattern = new UrlPattern('/thread/:threadId(/:name)(/:tab)');

const detectPath = (
    req: express.Request,
): RouteTypeObj | undefined => {
    const fullPath = req.baseUrl + req.path;
    if (fullPath === '/') {
        return {
            type: 'home'
        }
    }

    const postParams = postPattern.match(fullPath)
    if (postParams && postParams.postId) {
        return {
            id: postParams.postId,
            type: 'post',
        }
    }

    const threadParams = threadPattern.match(fullPath)
    if (threadParams && threadParams.threadId) {
        return {
            type: 'thread',
            id: threadParams.threadId,
        }
    }
    return undefined

```

### URLのパスからどの様にデータをfetchしているのか

クライアント側では`src/util/query.ts`内からfirestoreを通じデータを取得することができる。このサーバーファイルも`src/server.ts`に置くことでデータの取得ロジックを再利用できる。

```

import { getCommentById, getCommunityById } from './../util/query'

// リクエスト毎(isbot==trueの場合） 
const c = await getCommentById('', type.id)
if (!c) {
    return undefined
}
return {
    title: c.title,
    desc: markdownToText(c.comment),
}

```

### フォルダ構造はどの様になってるのか

サーバー側のコードは`src/server.ts`や`src/handler`フォルダで実装されていて、クライアント側のコードとフォルダが混ざっている。当初`server`フォルダを作ったのだが`jest`や`tsconfig.json`でうまく`rootDir`を設定できなくなったり`package.json`を分けることもできなかった。複雑なのでレポを分ける方が無難だと思ったがメンテナンスが増えるので、同じフォルダ内で実装することにした。よく考えれば`tsconfig.json`と`jest.config.js`の`include`と`exclude`でビルドやテスト方法を分別すれば問題はなかった。

また、今後サーバー側の開発はあまり行わない前提なのでこの方法はワークする。
ちなみにワークしたconfigは以下。

**server.tsconfig.json**

```

{
    "compilerOptions": {
      "target": "es6", 
      "module": "commonjs", 
      "sourceMap": true, 
      "outDir": "./server-build", 
      "strict": true, 
      "moduleResolution": "node", 
      "esModuleInterop": true,
      "forceConsistentCasingInFileNames": true,
      "allowJs": true
    },
    "include": [
      "src/server.ts",
      "src/util/query.ts"
    ],
    "exclude": ["node_modules", "**/*.test.ts"]
  }
```

**server.jest.config.js**

```
module.exports = {
    "collectCoverage": true,
    "rootDir": "./src",
    "roots":[
        "<rootDir>/handler",
    ],
    "testRegex": "__tests__/.+\\.test\\.ts",
    "transform": {
      '^.+\\.js?$': "ts-jest"
    },
    globals: {
        'ts-jest': {
            babel: true,
            tsConfig: "server.tsconfig.json"
        }
    },
    "preset": "ts-jest",
    "moduleFileExtensions": ["js", 'ts'],
    "moduleDirectories": [
      "node_modules",
      "lib"
    ]
  }
```


**package.json**（depsは省略）

```
+   "build-server": "tsc --p server.tsconfig.json",
+   "run-server": "node server-build/server.js",
+   "test-server": "./node_modules/.bin/jest --config=server.jest.config.js --coverage=false",
```

**Dockerfile**

```
+ RUN yarn build-server

- CMD serve -s build 
+ CMD IS_SERVER=true NODE_ENV=production yarn run-server 
```

**.circleci/config.yml**

```
+ - run: yarn build-server
+ - run: yarn test-server
```

**.dockerignore, .gitignore**

```
+ server-build
```

### Metaタグはどう返すのか

ビルドされた`index.html`を`String.replace()`で置き換えれる。

```

// 初期時に実行
const index = fs.readFileSync(
    path.join(
        __dirname,
        './../../build',
        'index.html',
    ), 'utf8')


const generateMeta = (
    title: string,
    desc: string,
    url: string,
) => {
    return `
    <meta property="title" content="${title}" />
    <meta property="description" content="${desc}" />

    <meta property="og:title" content="${title}" />
    <meta property="og:description" content="${desc}" />
    <meta property="og:image" content="https://kidok.app/logo512x512.png" />
    <meta property="og:url" content="${url}" />

    <meta name="twitter:title" content="${title}" />
    <meta name="twitter:description" content="${desc}" />
    <meta name="twitter:image" content="https://kidok.app/logo512x512.png" />
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:site" content="@KidokApp" />
    <meta property="fb:app_id" content="591587255186708" />
    <meta property="og:site_name" content="コミュニティーを繋げる | キドク" />
    <meta name="twitter:image:alt" content="コミュニティーを繋げる | キドク" />
`
}

// リクエスト時(isbot==trueの場合)
const c = await getMeta(type)
if (!c) {
    next()
    return
}
const url = req.url;
const meta = generateMeta(
    c.title || '',
    c.desc,
    url,
)

res.setHeader('Content-Type', 'text/html')
res.status(200)
const newHtml = index.replace('</head>', meta + '</head>')
res.send(newHtml);
res.end();


```

### テストは書いたのか


人為的ミスが起きそうなのでテストを書いた。request/responseのモックは[@jest-mock/express](https://www.npmjs.com/package/@jest-mock/express)を使い、middlewareに注入する。テストは300+列ほどあるので、一部分だけをここで共有する。

```

const reqFn = (
    ua: string,
    path: string = '/'
) => getMockReq({
    headers: {
        'user-agent': ua,
    },
    path: path,
})


describe('Get test', () => {

    let req: express.Request
    let res: express.Response
    let next: express.NextFunction
    let clearMockRes: () => void

    beforeEach(() => {
        const obj = getMockRes({})
        res = obj.res
        next = obj.next
        clearMockRes = obj.clearMockRes
    })

    afterEach(() => {
        clearMockRes()
        jest.resetAllMocks()
    })

    describe('home', () => {
        it('should return home page meta data if route is home',
            async () => {
                const myua = 'Twitterbot/1.0'
                const req = reqFn(myua, '/')
                await detectBot(
                    req,
                    res,
                    next,
                )

                expect(res.send).toHaveBeenCalled()
                expect(next).not.toHaveBeenCalled()
                validateFb(res.send)
                validateTwitter(res.send, fullBrandTitle, '')
            })
    })

    describe('thread', () => {

        it('return thread meta data if thread route is accessed',
            async () => {
                const getCommunityByIdFn = jest.spyOn(query, 'getCommunityById')
                    .mockImplementation((id: string): Promise<query.CommunityOrUndefined> => {
                        return new Promise((resolve) => resolve({
                            id: '1',
                            name: 'community name',
                            description: 'community desc',
                            nsfw: false,
                            roles: {},
                            subscribeCount: 0,
                        }))
                    });
                const getCommentByIdFn = jest.spyOn(query, 'getCommentById')

                const myua = 'Twitterbot/1.0'
                await detectBot(
                    reqFn(myua, '/thread/abcdefg/12345'),
                    res,
                    next,
                )

                expect(res.send).toHaveBeenCalled()
                expect(next).not.toHaveBeenCalled()
                validateFb(res.send)
                validateTwitter(res.send, 'community name', 'community desc')
                expect(getCommunityByIdFn).toHaveBeenCalledWith('abcdefg')
                expect(getCommentByIdFn).not.toHaveBeenCalled()
            })

        it('should call next if thread post data does not exist',
            async () => {
                const getCommunityByIdFn = jest.spyOn(query, 'getCommunityById')
                    .mockImplementation((id: string): Promise<query.CommunityOrUndefined> => {
                        return new Promise((resolve) => resolve(undefined))
                    });
                const getCommentByIdFn = jest.spyOn(query, 'getCommentById')
                    .mockImplementation((userid: string, commentid: string): Promise<IComment | undefined> => {
                        return new Promise((resolve) => resolve(undefined))
                    });

                const myua = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'
                await detectBot(
                    reqFn(myua, '/thread/abcdefg/12345'),
                    res,
                    next,
                )

                expect(res.send).not.toHaveBeenCalled()
                expect(next).toHaveBeenCalled()
                expect(getCommunityByIdFn).toHaveBeenCalledWith('abcdefg')
                expect(getCommentByIdFn).not.toHaveBeenCalled()
            })
    })
})

```

### その他

最後に[sentry](https://sentry.io/)を付け足し完了。

**src/server.ts**

```
import * as Sentry from "@sentry/node";
// Importing @sentry/tracing patches the global hub for tracing to work.
import * as Tracing from "@sentry/tracing";

Sentry.init({
    dsn: DSN,
    environment: process.env.NODE_ENV || 'development',
    tracesSampleRate: 1.0,
});


process.on('uncaughtException', (err, origin) => {
    Sentry.captureException(err)
    process.exit(1)
});

```

### 最後に


SSRに置き換えるのは今後のReactでの開発工数を含め大変だったので防ぎたかった。なので`<meta>`タグのみでSNSのプレビューができたので良かった。


| SNS | Before | After |
|--|--|--|
| Facebook | ![Screen Shot 2021-09-19 at 12.57.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/8f3ff1e3-2748-f29b-96fe-f600b0d67f79.png) | ![Screen Shot 2021-09-19 at 12.58.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/4acb72b4-9ea7-a1f1-14b6-a2d97f0eb934.png) |
| LINE |  ![Screen Shot 2021-09-19 at 12.57.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/d16f969e-10d3-0eda-214b-9e4ff57e3fff.png) | ![Screen Shot 2021-09-19 at 12.58.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1997673/b0c6bd69-a62d-04f1-d112-788accd7f9fd.png) |



興味ある方、加わって行きたい方はコメントか[Twitter][21]でDMください。
応援したい方は[ウェブサイト][22]で色々投稿するか、Qiitaと[Twitter][21]のフォローお願いします！
ちなみに[Note][20]も書いてます！

[20]: https://note.com/kidok/n/n9cd418b3fba6
[21]: https://twitter.com/kidokapp
[22]: https://kidok.app/thread/0ZZ6GEvbVnMSDwFnxmfV/%E3%81%AA%E3%82%93%E3%81%A7%E3%82%82%E8%81%9E%E3%81%84%E3%81%A6/post/cg3Q92pYBeCvDOONiz2h?u=qiita