【Laravel】URLからツイッターみたいなカードリンクをつくる

こんにちは❗フリーランス・エンジニアの 九保すこひ です。

さてさて、個人的にはあまりTwitterでの情報発信はしていないのですが、最近Twitterに関連する開発をしていて「あっ、これあったらいいよね😊」と思う機能を発見しました。

それが・・・・・・

カード形式のリンク

です。

例えばこんな感じですね。
Twitterの開発ページから引用

これってかっこいいですよね。

そこで❗

今回はURLを元にしてページ情報を取得しカードリンクとして表示する方法をご紹介します。

ぜひ皆さんの参考になれば嬉しいです😊✨
(最後にソースコード一式をダウンロードできますよ👍)

「最近、ちょっとずつ断捨離しています」

開発環境: Laravel 7.x, Vue 2.6.11

モデル&データベースの準備をする

では最初はデータベースまわりを作っていきます。
以下のコマンドを実行してください。

php artisan make:model Card -m

すると、モデルとマイグレーションのファイルが一気に作成されます。

モデルの設定

では、モデルの設定からです。

app/Card.php

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Card extends Model
{
    protected $guarded = ['id'];

    // Accessor
    public function getTitleAttribute($value) {

        if(empty($value)) {

            return '(タイトルなし)';

        }

        return mb_strimwidth($value, 0, 80, '...');

    }

    public function getDescriptionAttribute($value) {

        $value = preg_replace('/^[\s ]+/u', '', $value); // 先頭の全角・半角スペース除去
        $value = preg_replace('/[\s ]+$/u', '', $value); // 最後の全角・半角スペース除去
        return mb_strimwidth($value, 0, 150, '...');

    }

    public function getThumbnailUrlAttribute($value) {

        if(empty($value)) {

            return url('images/no_image_available.png'); // サムネイル画像がないとき

        }

        return $value;

    }

    // Others
    public function setAttributes($url) {

        $this->url = $url;

        try {

            $html = file_get_contents($url);

        } catch (\Exception $e) {

            return false;

        }

        $default_libxml_error = libxml_use_internal_errors(true); // libxml エラー設定を変更

        $doc = new \DOMDocument();
        $doc->loadHTML($html);

        libxml_use_internal_errors($default_libxml_error); // libxml エラー状態を元にもどす

        $tags = $doc->getElementsByTagName('meta');
        $attributes = [
            'title' => '',
            'thumbnail_url' => '',
            'description' => ''
        ];

        foreach($tags as $tag) {

            if($tag->hasAttribute('property') && $tag->hasAttribute('content')) { // 必要なデータを集める

                $property = $tag->getAttribute('property');
                $content = $tag->getAttribute('content');

                if($property === 'og:title') {

                    $attributes['title'] = $content;

                } else if($property === 'og:image') {

                    $attributes['thumbnail_url'] = $content;

                } else if($property === 'og:description') {

                    $attributes['description'] = $content;

                }

            } else if($tag->hasAttribute('name')) {

                $property = $tag->getAttribute('name');
                $content = $tag->getAttribute('content');

                if(empty($attributes['description']) && $property === 'description') { // og:descriptionがないときの保険

                    $attributes['description'] = $content;

                }

            }

        }

        if(empty($attributes['title'])) { // og:titleがないときの保険

            $titles = $doc->getElementsByTagName('title');

            if(count($titles) > 0) {

                $title = $titles[0];
                $attributes['title'] = $title->textContent;

            }

        }

        $this->fill($attributes);
        return true;

    }
}

この中でやっているのは、まずLaravelが提供する便利機能、Accessorを使って以下3つのデータを加工するようにしています。

  • title: タイトルが存在していない場合に(タイトルなし)に入れ替える。テキストが長すぎる場合は切り詰める
  • description: 前後のスペースを除去し、テキストが長すぎる場合は切り詰める
  • thumbnail_url: サムネイル画像が存在しない場合、以下の画像に差し替える

※ ちなみに、この画像はパブリックドメイン(「自由に使っていいよ👍」ライセンス)です。リンク

そして、setAttributes()というメソッドを追加し、実行すると、該当ページから以下のデータを取得します。

  • <meta>タグの中から「og:title」「og:image」「og:description」を取得
  • 「og:title」「og:description」が存在しない場合、別のデータで代用する

なお、一点注目してほしいのが、libxml_use_internal_errors() です。

今回ページデータを取得するためにDOMDocument()と呼ばれるPHPの機能を使っていますが、libxml_use_internal_errors(true)を設定していないと「閉じタグがない」などのエラーが発生した場合に処理が止まってしまうからです。

※ ただし、そのままずっと変更したままでは他のコードに影響する可能性がありますので、元に戻しています。

マイグレーションの設定

では、続いてマイグレーションを設定します。

database/migrations/****_**_**_******_create_cards_table.php

// 省略

public function up()
{
    Schema::create('cards', function (Blueprint $table) {
        $table->id();
        $table->string('url')->comment('URL');
        $table->string('title')->nullable()->comment('タイトル');
        $table->string('thumbnail_url')->nullable()->comment('サムネイルURL');
        $table->text('description')->nullable()->comment('ページ概要');
        $table->timestamps();

        $table->unique('url');
    });
}

// 省略

なお、以下のフィールドは取得できない場合を想定してnullable()をつけています。

  • title
  • thumbnail_url
  • description

また、URLが重複しないようにunique()をつけていることにも注目してください。

では、この状態で以下のコマンドを実行しcardsテーブルをつくってみましょう。

php artisan migrate

実行が完了するとテーブルはこうなります。

カードリンクを表示するVueコンポーネントをつくる

次に、取得したページ情報を簡単に表示できるようにVueコンポーネント「v-card」をつくります。

以下のファイルを作成してください。

public/js/vue/v-card.js

Vue.component('v-card', {
    props: {
        url: String,
        ajaxUrl: String
    },
    data() {
        return {
            card: {}
        }
    },
    computed: {
        hasCard() {

            return (Object.keys(this.card).length > 0);

        },
        thumbnailStyle() {

            const thumbnailUrl = this.card.thumbnail_url;
            return {
                height: '100%',
                backgroundImage: `url(${thumbnailUrl})`,
                backgroundRepeat: 'no-repeat',
                backgroundSize: 'cover',
                backgroundPosition: 'center center'
            }

        }
    },
    mounted() {

        if(this.ajaxUrl !== '') {

            const params = { url: this.url };
            axios.post(this.ajaxUrl, params)
                .then(response => {

                    if(response.data.result === true) {

                        this.card = response.data.card;

                    }

                });

        }

    },
    template: `
        <div class="border rounded" style="text-decoration:none;" v-if="hasCard">
            <a :href="card.url" style="text-decoration:none !important;color:#333;" target="_blank">
                <div class="row">
                    <div class="col-4 pr-0">
                        <div :style="thumbnailStyle" class="rounded-left border-right"></div>
                    </div>
                    <div class="col-8 pl-0">
                        <div class="px-3 py-2">
                            <div class="font-weight-bold mb-1" v-text="card.title"></div>
                            <div class="mb-1" v-if="card.description">
                                <small v-text="card.description"></small>
                            </div>
                            <div class="mb-1" style="white-space:nowrap;overflow:hidden;text-overflow: ellipsis;" v-if="card.url">
                                <small class="text-secondary" v-text="card.url"></small>
                            </div>
                        </div>
                    </div>
                </div>
            </a>
        </div>
    `
});

この中で重要なのは、ajaxでデータを取得しているところです。
プロパティとして渡されるajaxUrlにアクセスしてページ情報を取得することになります。

※なお、CSSはBootstrap 4の利用を想定しています。

ページ情報を取得する部分をつくる

続いて、先ほど作成したv-card内からのajax送信を受ける部分をつくっていきます。目的は、OGPなどからページ情報を取得することです。

ルートをつくる

まずルートをつくります。

routes/web.php

Route::post('api/card/generate', 'Api\CardController@generate');

コントローラーをつくる

そして、専用コントローラーもつくりましょう。

php artisan make:controller Api\\CardController

中身は以下のように変更してください。

app/Http/Controllers/Api/CardController.php

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class CardController extends Controller
{
    public function generate(Request $request) {

        $card = null;
        $url = $request->url;
        $card = \App\Card::FirstOrNew(['url' => $url]);
        $exists = $card->exists();
        $dt_ago = now()->subMonth(); // 1ヶ月前

        if(!$exists|| ($exists && $card->updated_at < $dt_ago)) {

            if($card->setAttributes($url)) {

                $card->updated_at = now(); // 中身に変換がなくても必ず更新
                $card->save();

            } else { // HTTP接続に失敗した場合

                return false;

            }

        }

        return [
            'result' => true,
            'card' => $card
        ];

    }
}

この中でやっていることは次のとおりです。

  1. すでに該当URLのデータを持っているかチェック
  2. もし存在しない場合、または、存在しているけどデータが古い場合はデータを取得して保存します
  3. ページ情報を返す

テストしてみる

では、これで準備は完了しました。

以下のようなルートとテンプレートをつくってカードリンクがうまく表示されるかを見てみましょう!

※なお、ページ情報を取得するURLは、私が好きな「六甲ビール」さんに関するニュースです。個人的には苦味が強い「スサノオ」がおすすめ。(👈 企業案件ではありませんよ 笑)

routes/web.php

Route::get('test/card', function(){ return view('test_card'); });

resources/views/test_card.blade.php

<html>
<head>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css">
</head>
<body>
    <div id="app" class="p-3">
        <div class="row">
            <div class="col-6">
                <v-card :url="url" :ajax-url="ajaxUrl"></v-card>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
    <script src="/js/vue/v-card.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.2/axios.min.js"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                url: 'https://www.value-press.com/pressrelease/245407', // 該当URL
                ajaxUrl: '/api/card/generate'
            }
        });

    </script>
</body>
</html>

実際にブラウザでアクセスしたものが以下になります。

うまく表示されました。
成功です😊✨

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードできます。

ツイッターみたいなカードリンクをつくる

※ただし、マイグレーション等はご自身で行ってください。

おわりに

ということで、今回はTwitterのようなカード形式のリンクをつくってみました。

ユーザーさんからすると、リンク先の情報を先に確認できるので安心してクリックしやすいんじゃないでしょうか。

ちなみに、当初はaxiosを使わず、純正JavaScriptfetch()で実装してみようと思いましたが、csrfトークンエラー(419)が出てしまい、めんどうなので結局axiosを使うことにしました。

どうやらaxiosは自動でcsrfトークンを取得し、ヘッダーに含めてくれているようですね。さすがです👍

ということで、ぜひみなさんのサイトでカードリンクを使ってみてくださいね。

ではでは〜❗

「どんなに忙しくても最低15分は練習してます。
大した才能ないですが・・・」

この記事が役立ちましたらシェアお願いします😊✨ by 九保すこひ
また、わかりにくい部分がありましたらお問い合わせからお気軽にご連絡ください。
(また、個人レッスンも承ってます👍)
このエントリーをはてなブックマークに追加       follow us in feedly