0
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-09-20

2021 W37

先日キドクを公開した!(キドク実装方法 | Qiita記事

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

TL;DR

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

SNS Before After
Facebook Screen Shot 2021-09-19 at 12.57.43.png Screen Shot 2021-09-19 at 12.58.13.png
LINE Screen Shot 2021-09-19 at 12.57.49.png Screen Shot 2021-09-19 at 12.58.00.png

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

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

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

なのでSPAのようなウェブアプリは<noscript></noscript>のコンテンツのみが表示され頑張って書いたJSコードが実行されない。Twitterによればbody内のコンテンツよりも<meta>タグの内容だけでプレビューができる。この資料をもとに、他のSNSもOpen Graph Protocolに従っていると想定した。

SSRは必要なのか

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

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

簡単にいうとUser-Agentヘッダーで検知が可能だ。以下の公式資料をもとに性能が良いと思ったモジュールがisbotだ。isbotは性能が良くデモで確認できる。ちなみにUser-Agent空の場合もあるが、そこまで気にすることでもないみたい。

Facebookクローラー公式資料
Twitterクローラー公式資料
LINEクローラー公式資料


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のドキュメント通りカスタマイズする方法が書かれてたのでこれを元に、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を使い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.tssrc/handlerフォルダで実装されていて、クライアント側のコードとフォルダが混ざっている。当初serverフォルダを作ったのだがjesttsconfig.jsonでうまくrootDirを設定できなくなったりpackage.jsonを分けることもできなかった。複雑なのでレポを分ける方が無難だと思ったがメンテナンスが増えるので、同じフォルダ内で実装することにした。よく考えればtsconfig.jsonjest.config.jsincludeexcludeでビルドやテスト方法を分別すれば問題はなかった。

また、今後サーバー側の開発はあまり行わない前提なのでこの方法はワークする。
ちなみにワークした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.htmlString.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を使い、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を付け足し完了。

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 Screen Shot 2021-09-19 at 12.58.13.png
LINE Screen Shot 2021-09-19 at 12.57.49.png Screen Shot 2021-09-19 at 12.58.00.png

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0