Hugoでブログを書いていてずっと欲しかったブログカードをshortcodeで実装してみました。
はてなブログでURLを「埋め込み」形式で貼り付けたときのイメージを目指します。

以下の流れでやっていきます。

  1. 指定したURLのogp情報を取得するNetlify Functionを作成
  2. Netlify Functionから取得したogp情報を元にブログカードを生成するshortcodeを作成
  3. ブログ記事からshortcodeを呼び出す

取得する情報

当ブログにも設置していますがheadタグ下のmetaタグから必要な情報を取得します。
記事の「タイトル」「アイキャッチ画像」「記事の説明」が今回取得したいものになります。
Twitter Cardからも欲しい情報は取得できますが今回はogpタグから取得します。

<meta name="description" content="Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話">
<meta property="og:title" content="Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話 - A1 Blog">
<meta property="og:type" content="article">
<meta property="og:url" content="https://blog.a-1.dev/post/2019-04-13-docker-mount/">
<meta property="og:image" content="https://blog.a-1.dev/images/docker-mount-error/title.png">
<meta property="og:site_name" content="A1 Blog">
<meta property="og:description" content="Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話">
<meta property="og:locale" content="ja_JP">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="A1 Blog">
<meta name="twitter:url" content="https://blog.a-1.dev/post/2019-04-13-docker-mount/">
<meta name="twitter:title" content="Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話 - A1 Blog">
<meta name="twitter:description" content="Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話">
<meta name="twitter:image" content="https://blog.a-1.dev/images/docker-mount-error/title.png">

Netlify Functionsを実装する

NetlifyにはAWS Lambdaが無料で使えるFunctionsという機能があります。
執筆時点ではNode.jsとGoしか使えませんが、125,000アクセス/月、100時間/月まで無料で利用できるという神サービスです。
今回は、Functionsを使用して指定したURLのogp情報をJSONで返すAPIを実装します。

Netlify Functionsを有効化

functions="./functions"をnetlify.tomlに追加してFunctionsを配置するフォルダを指定します。
指定するフォルダはデフォルトの./functionsフォルダとしました。
あと、commandに後ほど設定するnpm run buildをhugoコマンドの実行前に追加しておきます。

[build]
command = "npm run build && hugo --gc --minify --enableGitInfo"
functions="./functions"

netlify-lambdaのインストール

Functionをローカルでデバッグするため、netlify-lambdaをインストールします。

$ npm install --save-dev netlify-lambda
  • netlify-lambda serve

$(npm bin)/netlify-lambda serve <ソースフォルダ> を実行するとローカルにサーバーが立ち上がりhttp://localhost:9000/<Function名>でデバッグできるようになります。

  • netlify-lambda build

$(npm bin)/netlify-lambda build <ソースフォルダ> を実行すると内蔵されているwebpackが実行され./functionsフォルダにjsファイルが作られます。

それぞれpackage.jsonのscriptsに追加しておきましょう。

"scripts": {
  "build": "netlify-lambda build src/functions/",
  "serve": "netlify-lambda serve src/functions/"
}

ogp-parserをインストール

今回、ogp-parserというライブラリを使用させてもらってogp情報を取得しますのでインストールしておきます。

$ npm install --save ogp-parser

ogp-parserを実行するとこんな感じのJSONが取得できます。

{ title: 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話 - A1 Blog',
  ogp:
   { 'og:title': [ 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話 - A1 Blog' ],
     'og:type': [ 'article' ],
     'og:url': [ 'https://blog.a-1.dev/post/2019-04-13-docker-mount/' ],
     'og:image':
      [ 'https://blog.a-1.dev/images/docker-mount-error/title.png' ],
     'og:site_name': [ 'A1 Blog' ],
     'og:description': [ 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話' ],
     'og:locale': [ 'ja_JP' ] },
  seo:
   { pinterest: [ 'nopin' ],
     viewport: [ 'width=device-width,minimum-scale=1,initial-scale=1' ],
     'theme-color': [ '#263238' ],
     generator: [ 'Hugo 0.55.0' ],
     description: [ 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話' ],
     'twitter:card': [ 'summary_large_image' ],
     'twitter:site': [ 'A1 Blog' ],
     'twitter:url': [ 'https://blog.a-1.dev/post/2019-04-13-docker-mount/' ],
     'twitter:title': [ 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話 - A1 Blog' ],
     'twitter:description': [ 'Mac OSのDockerで/etc/localtimeがマウントできなくなって困った話' ],
     'twitter:image':
      [ 'https://blog.a-1.dev/images/docker-mount-error/title.png' ] 
   }
}

<meta property=...>で定義されたものはogpというキーに情報が格納されるので基本的にこの情報を使用します。
かたやseoというキーには<meta name=...>で定義されたものが格納されるようなので、viewportなどSEOと関係ない情報も格納されるようです。
<meta name="og:image"...>という変則的(バグ?)なサイトもあったので、seoキーの方からもog:xxxxにマッチする情報を取得していきます。

Functionを実装

src/functionsに任意の名前のファイル名でFunctionsを実装します。ファイル名がそのままFunctionの名前になります。

exports.handler = (event, context, callback) => {

  if ('url' in event.queryStringParameters === false) {
    console.error("parameter 'url' is necessary!!");
    return;
  }

  const url = event.queryStringParameters.url;
  const parser = require("ogp-parser");
  parser(encodeURI(url), true).then(function(data) {
    console.log(data);
    if (!data.hasOwnProperty('title')) {
        console.error("Error getting ogp data: no ogpData returned");
        return res.json({ error: "no ogpData returned" });
    } 
    let ogpData = {};
    ogpData['siteName'] = data.title;
    for (let prop in data.ogp) {
        if (/^og:/g.test(prop)) {
            ogpData[prop.split(':')[1]] = data.ogp[prop][0];
        }
    }
    for (let prop in data.seo) {
      if (/^og:/g.test(prop)) {
          ogpData[prop.split(':')[1]] = data.seo[prop][0];
      }
    }
    console.log(JSON.stringify(ogpData));
    callback(null, {
      statusCode: 200,
      "headers": { "Content-Type": "application/json; charset=utf-8"},
      body: JSON.stringify(ogpData)
    });
  }).catch(function(error) {
      console.error(error);
  });

};

やっていることは以下の通り。

  1. GETパラメータでurlを取得
  2. opg-parserでopg情報を取得
  3. 取得した情報から必要な情報に絞ったJSONを返却

Shortcodeの実装

事前に、config.tomlにFunctionのURLを登録して$.Page.Site.Params.OgpApiEndpointで取得できるようにしておきます。ちなみにローカルと違って、/.netlify/functions/<Function名>のように長い形式でしかアクセスできないようです。

[params]
  OgpApiEndpoint = "https://<netlifyのURL>/.netlify/functions/<Function名>?url="

shortcodeを実装してogp.htmlとして保存します。
まず、getJSONでFunctionを呼び出し、取得したJSONからHTMLを生成します。
HTMLタグも、CSSも、はてなブログのものを殆どそのまま真似させてもらいました。

{{ $url := .Get 0 }}

{{ $jsonData := getJSON $.Page.Site.Params.OgpApiEndpoint $url }}
{{ $siteName := $jsonData.siteName }}
{{ $title := $jsonData.title }}
{{ $description := $jsonData.description }}
{{ $image := $jsonData.image }}
{{ $urlInfo := urls.Parse $url }}
{{ $host := printf "%s://%s" $urlInfo.Scheme $urlInfo.Host }}
{{ $prefix := "https://www.google.com/s2/favicons?domain=" }}
{{ $favicon := printf "%s%s" $prefix $urlInfo.Host }}

<div class="body-iframe page-embed hatena-web-card">
    <div class="embed-wrapper">
        <div class="embed-wrapper-inner">
            <div class="embed-content with-thumb">
                <div class="thumb-wrapper">
                    <a href="{{ $url }}" target="_blank">
                        <img src="{{ $image }}" class="thumb">
                    </a>
                </div>
                <div class="entry-body">
                    <h2 class="entry-title">
                        <a href="{{ $url }}" target="_blank">
                            {{ $title }}
                        </a>
                    </h2>
                    <div class="entry-content">
                            {{ $description }}
                    </div>
                </div>
            </div>
            <div class="embed-footer">
                <a href="{{ $host }}" target="_blank">
                    <img src="{{ $favicon }}" alt="" title="{{ $title }}" class="favicon">
                    {{ $host }}
                </a>
                <img src="https://s.st-hatena.com/entry.count.image?uri={{ $url }}" alt="" class="star-count">
                <a href="http://b.hatena.ne.jp/entry/{{ $url }}" target="_blank">
                    <img src="https://b.hatena.ne.jp/entry/image/{{ $url }}" class="bookmark-count">
                </a>
            </div>
        </div>
    </div>
</div>
div.page-embed.hatena-web-card {
    height: 155px;
    border: 1px solid rgba(0, 0, 0, 0.1);
    margin-bottom: 10px;
}

div.page-embed.hatena-web-card div.embed-wrapper-inner {
    padding: 12px;
}

div.page-embed.hatena-web-card div.embed-content.with-thumb {
    height: 100px;
    overflow: hidden;
    position: relative;
}

div.page-embed.hatena-web-card div.embed-content.with-thumb .thumb-wrapper {
    position: absolute;
    top: 0;
    right: 0;
    width: 100px;
    height: 100px;
    overflow: hidden;
}
div.page-embed.hatena-web-card div.embed-content.with-thumb .thumb-wrapper .thumb {
    width: auto;
    max-width: 200%;
    height: 100px;
    border: none;
    display: block;
    position: relative;
    left: 50%;
    transform: translateX(-50%);
}

div.page-embed.hatena-web-card div.embed-content.with-thumb .entry-body {
    margin-right: 110px;
}

div.page-embed.hatena-web-card div.embed-content.with-thumb .entry-body .entry-title {
    font-size: 17px;
    margin: 0 0 2px;
    line-height: 1.4;
    max-height: 47px;
    overflow: hidden;
    border: none;
    background: #FFF;
    padding: 0;
}

div.page-embed.hatena-web-card div.embed-content.with-thumb .entry-body .entry-content {
    line-height: 1.5;
    font-size: 12px;
    max-height: 72px;
    overflow: hidden;
    border: none;
    padding-bottom:0;
}

div.page-embed.hatena-web-card div.embed-footer {
    margin-top: 8px;
    height: 15px;
    position: relative;
    font-size: 11px;
}

div.page-embed.hatena-web-card div.embed-footer img.favicon {
    display: inline;
    vertical-align: middle;
    border: none;
}

div.page-embed.hatena-web-card div.embed-footer img {
    box-shadow: none;
}

ブログから呼び出す

{{% ogp "<URL>" %}}でshortcodeを呼び出します。

完成イメージ

やっと完成した!はてなスターやブックマーク数も良い感じに表示されています。

ブログに写真や動画を貼り付ける「リンク挿入」機能を使いやすくしました URLをペーストするだけでコンテンツを埋め込めます - はてなブログ開発ブログ

はてなブログでは、写真や動画、音楽などさまざまなコンテンツを貼り付けできる リンク挿入 機能を、より簡単に使えるようにしました。編集画面にURLをペーストするだけで「リンクを挿入」ウィンドウが開き、いろいろなコンテンツを埋め込むことができます。 「リンクを挿入」ウィンドウでSoundCloudから音楽を埋め込む「リンクを挿入」ウィンドウでは、クリップボードから貼り付けたURLの種類によって、次のようなフォーマットでコンテンツをプレビューできます。 普通のWebページ …… タイトルを取得します 写真やイラストなどへの直リンク …… 画像として展開します 動画や音楽などリッチコンテンツを提供する…

※amp版は対応していないので、ただのリンクとして表示されています。

おわりに

大部分こちらのサイトを参考にさせてもらいました。ありがとうございます!

Hugoにブログカード埋め込みshortcodeを実装する

先日書いた記事で、他に自分が運営しているUCSB留学ブログを記事に埋め込んで紹介したのですが、実はその埋め込み作業にかなり手間がかかったのでまとめます。