2021 W37
先日キドクを公開した!(キドク実装方法 | Qiita記事)
徐々にUIの改善が行われた。今週の技術的なハイライトはソーシャルメディアのプレビューができる様になったことだ。
TL;DR
create-react-app (CRA)でウェブアプリを作ったが、ソーシャルメディアで共有すると、上手く表示されない。CRAにデフォルトで含まれていたserve -s build
を使っていた。これを少しの工夫でプレビューができた。
SNS | Before | After |
---|---|---|
![]() |
![]() |
|
LINE | ![]() |
![]() |
ソーシャルメディアのプレビューはどんな構造なのか
SNSで表示されるリンクのプレビューは以下の様な実装だ。
- URLをユーザーが入力する。
- SNS側のサーバーボットがURLをアクセスする。
- 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.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を使い、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 |
---|---|---|
![]() |
![]() |
|
LINE | ![]() |
![]() |
興味ある方、加わって行きたい方はコメントかTwitterでDMください。
応援したい方はウェブサイトで色々投稿するか、QiitaとTwitterのフォローお願いします!
ちなみにNoteも書いてます!