25
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterでWebサイトのメタデータ(OGP)を取得して表示する

Last updated at Posted at 2020-05-31

背景

たまに見かける、URLを入れるだけでタイトルやらサムネイル画像やらが自動でつく現象、あれはいったいどうやっているんだろうと思い、調査・実装してみたくなった。

作りたいもの

  • URLを入力して送信ボタンを押すと、指定したWebサイトのタイトルやサムネイル画像(?)を取得して表示するアプリ

作ったもの

ソースコード: https://github.com/popy1017/flutter_fetch_ogp

前提知識(Flutter関係ないけど重要)

<meta>タグとOGP(Open Graph Protcol)

初めは、HTML要素全部引っ張ってきて、タグを見て画像とか愚直に引っ張り出してるのかな〜(無知)と思ったけど、
どうやらhtmlで見かける<meta>タグから引っ張ってこれるらしい。
普通html書く時によく使うのは、こういうやつ。

<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>

<meta>タグには他にもいろいろ設定できて、例えば検索結果の各サイト名の下に表示される説明文なんかも<meta>タグで設定できる。

あれ?じゃあ画像とかもここで設定しているのでは・・・?(無知)と思いさらに調査を進めると、どうやらFacebookやTwitterなどのSNS用に、OGP(Open Graph Protcol) という規格が用意されているらしい。これこれぇ!!

OGPとは、「Open Graph Protcol」の略でFacebookやTwitterなどのSNSでシェアした際に、設定したWEBページのタイトルやイメージ画像、詳細などを正しく伝えるためのHTML要素です。

引用元: https://digitalidentity.co.jp/blog/seo/ogp-share-setting.html

LineでURLを貼った時にたまに、上の図みたいに画像とかタイトルとかつかないな〜と思っていたが、OGPが設定されていないURLだったんだ!という気付きを得て、ようやくアプリの実装へ。。。

(調査記録)Flutter(Dart)でWebサイトのmetaタグをとってくる

アプリを実装しようと思ったが、Dartでどうやってmetaタグをとってくるんだ?という疑問をまず解消してから先に進むことにした。
単純に考えれば、HTML要素全部取得して、正規表現とかでOGPの部分だけとってくれば良いのでは、それなら実装もできそう!と思ったが、
(誰かパッケージ化してくれているのでは。。。?)という淡い期待を抱きつつ調べると、あった!!
(ちなみに、Flutterのパッケージ公開サイトで"ogp"と調べた。)

metadata_fetch | Dart Package
ソースコード見た感じでは、やはり正規表現で抜き出しているっぽいので、頑張れば自分で実装できそう。

動作確認用のプロジェクトでいろいろ試して(割愛)、いざアプリ開発へ。。。
今回は以下の実装を参考にした。

Get_Open_Graph_Metadata
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:http/http.dart' as http;

void main () async {

  // Makes a call
  var response = await http.get('https://flutter.dev');

  // Covert Response to a Document. The utility function `responseToDocument` is provided or you can use own decoder/parser.
  var document = responseToDocument(response);

  // get metadata
  var data = MetadataParser.OpenGraph(document);
  print(data);
}

流れ

大まかな流れは以下の通り。
1. まずは画面を設計・実装する
2. アプリ全体で管理する状態(メタデータ)を追加する
3. ボタンを押したときの動作(OGPをとってきて、上記の状態に保存する)を実装する
4. 細部にこだわる

1. 画面の設計・実装

レイアウト案

今回はOGPの共通項目で一般的に(?)使われている、title, image, descriptionを取得して表示することとする。
ざっくりとした画面案は以下の通り。

実装

まずは、flutter createで作ったカウンターアプリを、以下のようにシンプルに書き換える。

main.dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter OGP Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MetaDataView(),
    );
  }
}

class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Center(
          child: Text("hello"),
        ));
  }
}

この状態から、MetaDataViewをいじってレイアウトを作っていく。

大雑把なレイアウト

まず、大きくわけて2つの部分(メタデータ部分、Form(入力欄とボタン)部分)を縦に並べたいので、Columnを使う。
次に、URL入力欄のところは固定の高さにしたいので、SizedBoxを使って高さを指定し、残りの部分にメタデータ部分を表示する。

MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            // わかりやすいように背景に色をつけている
            Expanded(child: Container(color: Colors.red)),
            SizedBox(height: 100),
          ],
        ));
  }
}

メタデータ部分(タイトル、画像、説明文)

次に、メタデータ部分に含めたい要素(タイトル、画像、説明文)を追加していく。
ここでもこの要素を縦に並べたいのでColumnを使う。
まだメタデータを取得する部分を実装していないので、例としてFlutterの公式ページのメタデータを表示する(ここはなんでも良い)。
Image.network(srcUrl)で、ネット上の画像を簡単に表示できる。

追記:
実機で動作確認したところ、TextFieldにフォーカスしたときにキーボードが出てくる影響で、
レンダーボックスがオーバーフローしてしまうので、ここではスクロール可能なListViewを使った方が良いです
実機検証大事。。。

MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            Expanded(
                child: Column(
              children: <Widget>[
                Text("Flutter - Beautiful native apps in record time"),
                Image.network(
                    "https://flutter.dev/images/flutter-logo-sharing.png"),
                Text(
                    "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
              ],
            )),
            SizedBox(height: 100, child: Container(color: Colors.cyan)),
          ],
        ));
  }
}
この段階では、表示される文字が端に近すぎるなどややおかしな点はあるが、そのあたりのこだわりは最後に実装する(自己暗示)。

Form部分(URL入力欄、Fetchボタン)

次にForm部分を構成する要素を追加する。
ここでも、要素を縦に並べたいのでColumnを使う。
入力欄はTextField(or TextFormField)で、ボタンはRaisedButtonで表示できる。

MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Column(
          children: <Widget>[
            ~~~~,
            SizedBox(
                height: 100,
                child: Column(
                  children: <Widget>[
                    TextField(),
                    RaisedButton(
                      onPressed: () {},
                      child: Text("Fetch"),
                      color: Colors.blue,
                      textColor: Colors.white,
                    ),
                  ],
                )),
          ],
        ));
  }
}
とりあえず大雑把なレイアウトは完成。 ...

やっぱりちょっと気になるので、全体をPaddingでラップする。

MetaDataView
class MetaDataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding( // <= 我慢できずPaddingを追加
          padding: const EdgeInsets.all(12.0),
          child: Column(
            children: <Widget>[
              Expanded(
                  child: Column(
                ~~~

各要素をWidgetに切り出す

このまま進めていくとネストがどんどん深くなり可読性が下がるので、ある程度まとまった単位でWidgetに切り出したい。
今回は、メタデータ部分をMetaDataDetailに、Form部分をFetchOgpFormに切り出す。
まずは切り出し先のファイルを作成する。(lib/配下にcomponents/を新規作成)

  • lib/components/metadata_detail.dart,
  • lib/components/fetch_ogp_form.dart,
metadata_detail.dart
import 'package:flutter/material.dart';

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Expanded(
        child: Column(
      children: <Widget>[
        Text("Flutter - Beautiful native apps in record time"),
        Image.network("https://flutter.dev/images/flutter-logo-sharing.png"),
        Text(
            "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source.")
      ],
    ));
  }
}
fetch_ogp_form.dart
import 'package:flutter/material.dart';

class FetchOgpForm extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(),
            RaisedButton(
              onPressed: () {},
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}

切り出し終わったら、MetaDataViewを書き換えて切り出したWidgetを表示する。
※ ここでメタデータの英語はmetadataであることに気づいたので、dを大文字にするのをやめて、MetaDataViewMetadataViewに変更しました。

main.dart_MetadataView
class MetadataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding(
          padding: const EdgeInsets.all(12.0),
          child: Column(
            children: <Widget>[MetadataDetail(), FetchOgpForm()],
          ),
        ));
  }
}

2. 状態管理(メタデータ、テキスト)を追加する

このアプリで管理する必要がある状態は、以下の2つ。

  • URL入力欄に入力された文字列
  • 取得したメタデータ

URL入力欄に入力された文字列は、ボタンを押したときに参照できれば良いのでFetchOgpFormで管理する。
取得したメタデータは、ぱっと見MetadataDetailのみから参照されそうだが、Fetchボタンを押したときに保持しているメタデータを更新したいので、FetchOgpFormからもアクセスできるようにする必要がある。そのため、2つのWidgetの親であるMetadataView(かそれより上)で状態を管理する。
(もっと良い設計があるかもしれない。)

URL入力欄に入力された文字列の状態を管理する

URL入力欄に入力された文字列は、1つのWidget(FetchOgpForm)でしか必要ないため、StatefulWidgetで状態を管理する。
そのためFetchOgpFormを書き換え、以下のようにする。

fetch_ogp_form.dart
import 'package:flutter/material.dart';

class FetchOgpForm extends StatefulWidget {
  @override
  _FetchOgpFormState createState() => _FetchOgpFormState();
}

class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(
              onChanged: (text) {
                setState(() {
                  _url = text;
                });
              },
            ),
            RaisedButton(
              onPressed: () {
                print("Current url is $_url");
                // fetchMetadata(_url);
              },
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}

これで、ボタン押した時にデバッグコンソールに入力された文字列が表示されるはず。

取得したメタデータの状態を追加する

上述の通り、このアプリで保持するメタデータは、FetchOgpFormから更新し、MetadataDetailで参照する必要があるため、それらの上のWidgetで管理する必要がある。
Flutterの状態管理は、上で使ったStatefulWidgetや、BLoCパターンなど様々な種類があるが、ここでは今流行り?のProviderパターンを使う。
(最近だとstate_notifierが出てきているが、使い所やProviderとの違い、優位点が理解できていない。)

provider, metadata_fetchのインストール

Providerパターンでは、providerパッケージを使うので、pubspec.yamlを編集してインストールする。
metadata_fetchパッケージをここでインストールする理由は、metadata_fetchで定義されているMetadataクラスを使うため。

pubspec.yaml
~~
dependencies:
  flutter:
    sdk: flutter

  provider: ^4.1.2

  metadata_fetch: ^0.3.1
~~

必要があればflutter pub getを実行する。

状態クラスを作る

ChangeNotifierを使って、状態を表すクラスを作る。
lib/配下にmodels/を作って、metadata_model.dartを新規作成。
まだ実際に指定したWebサイトからメタデータを取得する部分はないので、確認用にモックデータを定義。

lib/models/metadata_model.dart
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';

// テスト用。あとで消す。
Map<String, dynamic> mockJson = {
  "title": "Flutter - Beautiful native apps in record time",
  "image": "https://flutter.dev/images/flutter-logo-sharing.png",
  "description":
      "Flutter is Google's UI toolkit for crafting beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.  Flutter works with existing code, is used by developers and organizations around the world, and is free and open source"
};

class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);
  // fetchOgpFrom(String url) {};
}

各Widgetが状態クラスにアクセスできるようにする

ChangeNotifierProviderを使って、MetadataDetailwidgetとFetchOgpFormwidgetが上記の状態クラスにアクセスできるようにする。
(ChangeNotifierをprovideする)

main.dart_MetadataView
class MetadataView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text("'Flutter OGP Demo'")),
        body: Padding(
          padding: const EdgeInsets.all(12.0),
          child: ChangeNotifierProvider(
            create: (context) => MetadataModel(),
            child: Column(
              children: <Widget>[MetadataDetail(), FetchOgpForm()],
            ),
          ),
        ));
  }
}

MetadataDetailから状態クラスMetadataModelを参照する

状態変化の監視つきで状態を取得する場合は、context.selectを使う。(provider: 4.1.0以降)

metadata_detail.dart
import 'package:fetch_ogp/models/metadata_model.dart';
import 'package:flutter/material.dart';
import 'package:metadata_fetch/metadata_fetch.dart';
import 'package:provider/provider.dart';

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);

    return Expanded(
        child: Column(
      children: <Widget>[
        Text(_ogp.title),
        Image.network(_ogp.image),
        Text(_ogp.description)
      ],
    ));
  }
}

これで、変化が監視されたメタデータを表示できるようになった。

3. ボタンを押したときの動作を実装する

ここで行いたい処理は、FetchOgpFormから状態クラスMetadataModelにアクセスし、Metadata ogpを更新することである。
onPressedの中で更新を行っても良いが、更新用の関数をMetadataModelに定義してそれを呼び出した方が楽そうなのでそうする。

まずは指定したURLからHTML要素をとってくる必要があるので、httpパッケージをインストールする。

pubspec.yaml
dependencies:
  ~~
  http: ^0.12.1

OGPを取得する関数を定義する

MetadataModelに、OGPを取得してMetadata ogpにセットする関数を書いていく。
(各行でエラー起こる予感がするが、いったんエラー処理は割愛)
http.get()は非同期(Futureを返す)関数なので、awaitで待たせてもらう。

metadata_model.data_MetadataModel
class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);

  void fetchOgpFrom(String _url) async {
    final response = await http.get(_url);
    final document = responseToDocument(response);
    ogp = MetadataParser.OpenGraph(document);
    notifyListeners();
  }
}

OGPを取得する関数を呼び出す

上記で定義した関数をFetchOgpFormwidgetから呼び出したい。
ボタンを押した時の動作は、FetchOgpFormRaisedButtononPressedで定義する。
ちなみに、onPressednullのときはボタンが非活性になるので、入力状況によって活性・非活性を切り替えることができる。
状態クラスの関数にアクセスするためには、context.read<ModelName>().functionName()を使う。

※ FromとFormがややこしいので別の名前にすれば良かった

fetch_ogp_form.dart/_FetchOgpFormState
class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          ~~
            RaisedButton(
              onPressed: (_url == "")
                  ? null
                  : () {
                      print("Current url is $_url");
                      context.read<MetadataModel>().fetchOgpFrom(_url);
                    },
              child: Text("Fetch"),
              color: Colors.blue,
              textColor: Colors.white,
            ),
          ],
        ));
  }
}

これで、とりあえず入力したURLからメタデータをとってきて表示することができた。

4. 細部にこだわる

タイトルをタイトルっぽくする

現状では、文字が小さくてタイトル感がないので、スタイルを調整してタイトルっぽくしたい。
Textwidgetにスタイルを付与するには、style: TextStyle()を追加する。

MetadataDetail/Text
  Text(
    _ogp.title,
    style: TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 24,
    ),

ボタンをかっこよくする

ボタンを丸角にする

完全に好みだが、まるっぽいボタンの方が良いのでボタンを丸角にする。
ボタンを丸角にするためには、RaisedButtonwidgetにshape: StadiumBorder(),をつける。

fetch_ogp_form.dart/_FetchOgpFormState
~~
            RaisedButton(
              shape: StadiumBorder(),
              onPressed: (_url == "")
~~

ボタンにアイコンをつける

ボタンにアイコンをつけるためには、RaisedButton.icon()を使い、icon: Icon()を指定する。
またその際、文字列をアイコンの後に続ける場合はchildではなくlabelを使う必要がある必要がある点に注意。

fetch_ogp_form.dart/_FetchOgpFormState
~~
            RaisedButton.icon(
              shape: StadiumBorder(),
              icon: Icon(
                Icons.file_download,
                color: Colors.white,
              ),
              onPressed: (_url == "")
                  ? null
                  : () {
                      print("Current url is $_url");
                      context.read<MetadataModel>().fetchOgpFrom(_url);
                    },
              label: Text("Fetch"),
~~

(割と雑に)エラーに対処する

OGPが設定されていないURLが指定された場合

今の実装では、タイトル、画像、説明文のいずれかがWebサイト側で設定されていない場合、TextImagewidgetにnullが渡されてしまい、エラーとなってしまう。
そこでそれらの部分で、??(if null operator)を使って、nullの場合は決められた文字列を指定するようにする。

metadata_detail.dart
// No Imageと書かれた画像のフリー素材
final noImagePath =
    "https://www.shoshinsha-design.com/wp-content/uploads/2016/10/%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%BC%E3%83%B3%E3%82%B7%E3%83%A7%E3%83%83%E3%83%88-2016-10-05-0.41.12.png";

class MetadataDetail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final Metadata _ogp = context.select((MetadataModel _model) => _model.ogp);

    return Expanded(
        child: Column(
      children: <Widget>[
        Text(
          _ogp.title ?? "No title",
          style: TextStyle(
            fontWeight: FontWeight.bold,
            fontSize: 24,
          ),
        ),
        Image.network(_ogp.image ?? noImagePath),
        Text(_ogp.description ?? "No description")
      ],
    ));
  }
}

OGP取得の際に何かしらのエラーがあった場合に通知する

現在の実装では、URL形式でない文字列が与えられた場合や、通信できなかったときなど、様々なエラーが発生した場合への処理が何も書かれていない。
そのため、エラーが起きたときに、SnackBarと呼ばれる画面下からひょいっと出てくる小窓みたいなのを追加し、エラーが起きたことを伝えるようにする。

処理が失敗したか成功したかわからないので、fetchOgpFrom(url)関数を書き換える。

metadata_model.dart
class MetadataModel extends ChangeNotifier {
  Metadata ogp = Metadata.fromJson(mockJson);

  Future<bool> fetchOgpFrom(String _url) async {
    try {
      final response = await http.get(_url);
      final document = responseToDocument(response);
      ogp = MetadataParser.OpenGraph(document);

      notifyListeners();
      return true;
    } catch (e) {
      print(e.message ?? e);
      return false;
    }
  }
}

失敗したときのみSnackBarを表示するよう、FetchOgpFormonPressed()を書き換える。

fetch_ogp_form.dart
  onPressed: (_url == "")
    ? null
    : () async {
      print("Current url is $_url");
      final success = await context
        .read<MetadataModel>()
        .fetchOgpFrom(_url);

     if (!success) {
       final SnackBar _snackBar = SnackBar(
         content: Text("Error happened."),
         backgroundColor: Colors.red[300],
       );
       Scaffold.of(context).showSnackBar(_snackBar);
     }
   },
余談

本当はエラー内容に応じてメッセージを変えたかったが、すぐにはできなさそうだったので別の機会にやることにした。

URL入力欄にプレースホルダーを表示する

ぱっと見何を入力すれば良いのかわからないので、プレースホルダーでわかりやすくする。
TextFielddecoration: InputDecoration(~~)を指定するといろいろ設定できる。

fetch_ogp_form.dart
class _FetchOgpFormState extends State<FetchOgpForm> {
  String _url = "";

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        height: 100,
        child: Column(
          children: <Widget>[
            TextField(
              decoration: InputDecoration(hintText: "https://www.example.com"),
              onChanged: (text) {
                setState(() {
                  _url = text;
                });
              },
            ),
   ~~
25
14
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
25
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?