【Flutter】自給自足!Pornhubの検索アプリを作ってみた!【R18】

スポンサーリンク
PC創作
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
クマ〜〜〜〜〜〜
マイケル
マイケル
突然ですが、みなさんは
お気に入りのアダルトサイトのアプリがないか探したこと
はありませんか?
エレキベア
エレキベア
(マジで突然クマ・・・。)
マイケル
マイケル
そう健全な男子諸君なら一度はあるはず!
しかしAndroidはともかく、iOSアプリは審査が厳しいので
アダルト系はほぼ皆無状態
ですよね・・・。
マイケル
マイケル
だったら自分で作ってしまえばいいのではないか!
そう考えた私は、このようなアプリを作りました。
01 pornview mosic
\ドヤッ/
マイケル
マイケル
みんな大好きな有名アダルトサイト、
Pornhubの検索アプリです!!
エレキベア
エレキベア
(気合が入ってるクマ・・・。)
マイケル
マイケル
今回はこちらのアプリの実装内容を紹介します!
みなさんも是非、お気に入りのアダルトサイト検索アプリ作成
にトライしてみてくださいね!
ジャギィ
ジャギィ
(やってみようかな・・・。)
エレキベア
エレキベア
く、クマは熊だからみないクマが
手伝ってあげるクマ〜〜〜〜
マイケル
マイケル
それでは早速みていこう!!
スポンサーリンク

Flutterとは

マイケル
マイケル
このアプリは、モバイルアプリケーションフレームワークである
「Flutter(フラッター)」を使用して開発しました!
エレキベア
エレキベア
Flutterって何クマ??
マイケル
マイケル
Flutterはこのような特徴があるフレームワークなんだ!
Flutterとは
  • Googleによって開発されたモバイルアプリケーションフレームワーク
  • マテリアルデザイン(※1)を基調しており、美しく統一感のあるコンポーネントを使用可能!
  • ホットリロード(※2)により高速に開発を行うことが可能!
  • マルチプラットフォームであり、iOS / Androidアプリの開発が可能!
 (※1) マテリアルデザイン ・・・ Googleが提唱したデザインシステム
 (※2) ホットリロード ・・・ 開発しながらリアルタイムに変更を反映できる機能

Screenshot 2020 12 19 10 51 18

エレキベア
エレキベア
またすごいのが出てきたクマね〜〜〜
マイケル
マイケル
似たようなフレームワークで React Native もあるけど、
最近勢いが増しているFlutterを使用することにしたよ!
マイケル
マイケル
ホットリロードがほんとに便利で、
開発が気持ちよかったです・・・。
Screenshot 2020 12 19 1 42 34↑ホットリロードでの開発
エレキベア
エレキベア
(意味深に聞こえるクマ・・・。)
でもなんだかワクワクしてきたクマ〜〜〜!!
マイケル
マイケル
よーし、それじゃ開発にとりかかるぞ!
スポンサーリンク

参考書籍

マイケル
マイケル
Flutterで開発するにあたって、
下記の書籍を参考にさせていただきました!

マイケル
マイケル
Flutterの実装内容が豊富なため、辞書的な使い方になるかと思います!
サンプルアプリは少ないため、何でもいいから一つ作りたい!という方には
下記の書籍をおすすめします!

マイケル
マイケル
2020年12月現在、参考書は大体この3つになると思います。
エレキベア
エレキベア
精進するクマ〜〜〜・・・。
ジャギィ
ジャギィ
(勉強してみようかな)
スポンサーリンク

検索アプリの設計

マイケル
マイケル
それでは今回作ったアプリの仕様を簡単に説明します!
エレキベア
エレキベア
待ってましたクマ
Screenshot 2020 12 19 2 53 20
マイケル
マイケル

① 検索条件を入力後、表示件数を選択して検索ボタンを押下する。
② Pornhubのサイトをスクレイピングして動画情報の一覧を取得する。
③ 動画をクリックすると該当動画のURLを内部ブラウザとして表示する

というシンプルな仕様になります!
エレキベア
エレキベア
動画視聴のページはURLから表示することで
実装も最小限に抑えているクマね
マイケル
マイケル
広告抜いたら営業妨害になりそうだしね。
このようなアプリを開発・使用することで
得られるメリットは大きく下記3点になります!
アダルト検索アプリを自作するメリット
  • 自分で開発しているため、安全性が保たれる。
  • 検索件数等、条件を自分でカスタマイズできる。
  • ブラウザを毎回開かずにすみ、悪意ある広告によるクラッシュも回避できる。
マイケル
マイケル
特にWEBに落ちている怪しいアプリをインストールせずにすむ
というのは安全面的にかなりいいかと思います!
エレキベア
エレキベア
そういうのはウイルスが含まれていたり
広告の数も半端なかったりするクマからね
マイケル
マイケル
自分で作ったアプリだと愛着も湧くだろうしね。
それじゃこの仕様で開発に入っていこう!
エレキベア
エレキベア
クマ〜〜〜〜〜〜〜
スポンサーリンク

検索アプリの開発

環境構築 ※2021/7/4追記

マイケル
マイケル
Flutterの環境構築や基本的な使い方については、この記事では省略させていただきます。
Flutterを初めて触るという方は下記の公式ページを参考に環境構築とチュートリアルを試してみてください!
インストール|Flutter Doc JP
チュートリアル|Flutter Doc JP

使用するパッケージ

マイケル
マイケル
まずは事前準備として、今回使用する外部パッケージを
取り込みましょう!
今回使用した外部パッケージは下記になります。


[外部パッケージ]
・page_transition(ページ遷移アニメーション)
・webview_flutter(内部ブラウザ表示)
・universal_html(スクレイピング)
・google_fonts(フォント)

マイケル
マイケル
このパッケージをpubspec.yamlに記述し、
pub getして取り込んで
おきましょう!
・・・略・・・

dependencies:
  flutter:
    sdk: flutter
  page_transition: ^2.0.2
  webview_flutter: ^2.0.9
  google_fonts: ^2.1.0
  universal_html: ^1.1.16

・・・略・・・
↑外部パッケージの記述(2021年7月時点の最新バージョン)
※20210/7/25追記
Flutterの最新バージョンに合わせてNullSafetyに対応したパッケージにする必要があったため、
バージョンを上げて修正しました。
既存のものをアップグレードする場合、「dart pub outdated –mode=null-safety」コマンドを叩いてみてください。
参考:Flutter2のDart Null Safetyを既存のプロジェクトに導入する
エレキベア
エレキベア
これだけで使用できるのは便利くまね

画面の実装

マイケル
マイケル
設定ができたら実際に画面を実装します!
少し長いですが、完成系を載せています。
まずはmain.dart を下記内容で置き換えましょう!
import 'package:flutter/material.dart';
import 'package:porn_clone/video_page.dart';
import 'package:page_transition/page_transition.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:universal_html/driver.dart' as driver;

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

class MainPage extends StatefulWidget {
  @override
  MainPageState createState() => MainPageState();
}

class MainPageState extends State<MainPage> {
  final List<String> _dispMaxCountList = [
    "20",
    "50",
    "100",
    "200",
    "500"
  ]; // 表示件数リスト
  String _selectDispMaxCount = "20"; // 表示件数(選択)

  List<MovieInfo> _movieList; // 動画リスト
  String _searchWord; // 検索キーワード
  bool _loading = false; // ローディング状態

  // 動画リスト取得処理
  void getMovieList() async {
    // 入力されていない場合は処理なし
    if (_searchWord == null || _searchWord.length == 0) {
      return;
    }

    // ローディング状態にする
    setState(() {
      _loading = true;
    });

    // 検索結果ページを取得
    final url = "https://jp.pornhub.com/video/search?search=" + _searchWord;
    final client = driver.HtmlDriver();
    await client.setDocumentFromUri(Uri.parse(url));

    // 検索件数文章取得
    final searchCountElm = client.document.querySelector(".showingCounter");
    if (searchCountElm == null) {
      // 取得結果無しの場合
      setState(() {
        _loading = false;
      });
      return;
    }

    // 検索結果件数を取得
    int searchCount = 0;
    var regExp = new RegExp('[0-9]{2,}');
    Iterable<Match> matches = regExp.allMatches(searchCountElm.text.trim());
    if (matches.isNotEmpty) {
      searchCount = int.parse(
          matches.last.group(0).toString()); // 検索結果件数:「1~XXを表示中(XXの中から)」から抽出

    }
    // 最大表示件数以上の場合、最大表示件数を設定
    if (searchCount > int.parse(_selectDispMaxCount)) {
      searchCount = int.parse(_selectDispMaxCount);
    }
    // 表示ページ数取得
    int dispPageCount = (searchCount / 20).ceil(); // ページ数:検索結果件数 / 20件 (繰上)

    // 検索結果のリスト取得
    List<MovieInfo> tmpList = new List<MovieInfo>();
    for (var i = 0; i < dispPageCount; i++) {
      // 2ページ目以降の場合、URLを再取得
      if (i > 0) {
        final nextUrl = url + "&page=" + (i + 1).toString();
        await client.setDocumentFromUri(Uri.parse(nextUrl));
      }

      // 動画情報の一覧を取得
      final elements = client.document
          .querySelectorAll("#videoSearchResult > .pcVideoListItem");

      // タイトル、画像、URLを取得
      for (final elem in elements) {
        final titleElm = elem.querySelector(".title");
        final imageElm = elem
            .querySelector(".videoPreviewBg > img")
            .getAttribute('data-src');
        final urlElm = "https://jp.pornhub.com" +
            elem.querySelector("a").getAttribute('href');
        tmpList.add(new MovieInfo(titleElm.text.trim(), urlElm, imageElm));
      }
    }

    // 取得した動画リストを設定
    setState(() {
      _movieList = tmpList;
      _loading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: HexColor("1B1B1B"),
      ),
      home: Scaffold(
        appBar: AppBar(
          centerTitle: true,
          leading: Padding(
            padding: EdgeInsets.only(left: 10.0),
            child: Icon(
              Icons.camera,
              color: HexColor("ff9900"),
            ),
          ),
          title: Text(
            "Pornhub Viewer",
            style: GoogleFonts.mPlus1p(),
            textAlign: TextAlign.justify,
          ),
        ),
        body: Container(
            color: Colors.black,
            child: Column(
              children: <Widget>[
                // *** 検索条件入力部 ***
                Row(
                  children: <Widget>[
                    // 表示件数プルダウン
                    DropdownButton<String>(
                        itemHeight: 55,
                        value: _selectDispMaxCount,
                        selectedItemBuilder: (context) {
                          return _dispMaxCountList.map((String item) {
                            return Center(
                              child: SizedBox(
                                height: 20,
                                width: 35,
                                child: Text(
                                  item,
                                  textAlign: TextAlign.center,
                                  style: TextStyle(
                                    color: Colors.white,
                                  ),
                                ),
                              ),
                            );
                          }).toList();
                        },
                        items: _dispMaxCountList.map((String item) {
                          return DropdownMenuItem(
                              value: item,
                              child: Text(
                                item,
                                style: TextStyle(
                                  color: Colors.black,
                                ),
                              ));
                        }).toList(),
                        onChanged: (String newValue) {
                          setState(() {
                            _selectDispMaxCount = newValue;
                          });
                        }),
                    // 検索条件入力蘭
                    Expanded(
                      child: Padding(
                        padding: EdgeInsets.all(3.0),
                        child: Container(
                          height: 40,
                          decoration: new BoxDecoration(
                            color: Colors.white,
                          ),
                          child: TextFormField(
                            decoration: const InputDecoration(
                              icon: Icon(Icons.search),
                              hintText: "input search word.",
                            ),
                            onChanged: (text) {
                              setState(() {
                                _searchWord = text;
                              });
                            },
                          ),
                        ),
                      ),
                    ),
                    // 検索ボタン
                    Container(
                      height: 45,
                      padding: EdgeInsets.all(3),
                      child: RaisedButton(
                          child: Text(
                            "SEARCH",
                            style: TextStyle(color: HexColor("1B1B1B")),
                          ),
                          color: HexColor("ff9900"),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(5),
                          ),
                          onPressed: _loading
                              ? null
                              : () {
                                  getMovieList(); // 動画リスト取得
                                }),
                    ),
                  ],
                ),
                // *** インジケータ表示部 ***
                _loading ? LinearProgressIndicator() : Container(),
                // *** 動画リスト表示部 ***
                Expanded(
                  child: _movieList == null
                      ? Text(
                          "Please input search word!!",
                          style: TextStyle(color: Colors.white),
                        )
                      : ListView.builder(
                          itemCount: _movieList.length,
                          itemBuilder: (context, index) {
                            return ListTile(
                              contentPadding: EdgeInsets.all(5.0),
                              // 引数にURLを設定
                              onTap: () async {
                                await Navigator.of(context).pushNamed('/movie',
                                    arguments: '${_movieList[index].url}');
                              },
                              // 画像・タイトル表示
                              leading:
                                  Image.network('${_movieList[index].image}'),
                              title: Text(
                                '${_movieList[index].title}',
                                style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 10,
                                ),
                              ),
                              trailing: Padding(
                                padding: EdgeInsets.all(5.0),
                                child: Icon(
                                  Icons.arrow_right,
                                  size: 20,
                                  color: Colors.white,
                                ),
                              ),
                            );
                          },
                        ),
                ),
              ],
            )),
      ),
      // 画面遷移設定
      // ※PageTransitionを使用
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/movie':
            return PageTransition(
                child: VideoPage(),
                type: PageTransitionType.rightToLeftWithFade,
                settings: settings);
            break;
          default:
            return null;
        }
      },
    );
  }
}

// 動画情報クラス
class MovieInfo {
  final String title, url, image;
  MovieInfo(this.title, this.url, this.image);
}

// カラーコード指定
class HexColor extends Color {
  static int _getColorFromHex(String hexColor) {
    hexColor = hexColor.toUpperCase().replaceAll('#', '');
    if (hexColor.length == 6) {
      hexColor = 'FF' + hexColor;
    }
    return int.parse(hexColor, radix: 16);
  }

  HexColor(final String hexColor) : super(_getColorFromHex(hexColor));
}
↑main.dartの記述
マイケル
マイケル
そして、動画画面として新たに video_page.dart ファイルを作成し、
下記内容に置き換えます!
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:google_fonts/google_fonts.dart';

class VideoPage extends StatefulWidget {
  @override
  VideoPageState createState() => VideoPageState();
}

class VideoPageState extends State<VideoPage> {
  WebViewController _controller;

  @override
  void initState() {
    super.initState();
    // Enable hybrid composition.
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  Widget build(BuildContext context) {
    // 遷移元よりURLを取得
    final String movieUrl = ModalRoute.of(context).settings.arguments;
    print("Request URL:" + movieUrl);

    return Scaffold(
      appBar: AppBar(
        title: Text(
          "Good Choice Σd(T□T)",
          style: GoogleFonts.mPlus1p(),
        ),
      ),
      body: WebView(
        initialUrl: movieUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController controller) {
          _controller = controller;
        },
      ),
    );
  }
}
↑video_page.dartの記述
マイケル
マイケル
これで実装は全てになります!
エレキベア
エレキベア
これだけの記述でアプリが作成できるなんてすごいクマ〜〜〜

各処理の概要

マイケル
マイケル
それでは実装内容の主な処理について解説します!
検索画面表示
Screenshot 2020 12 19 2 53 31
マイケル
マイケル
まずは検索画面のUI表示部分から!
こちらはMainPageStateクラスのbuildメソッド内に記述しています!


・・・略・・・

class MainPageState extends State<MainPage> {
  final List<String> _dispMaxCountList = [
    "20",
    "50",
    "100",
    "200",
    "500"
  ]; // 表示件数リスト
  String _selectDispMaxCount = "20"; // 表示件数(選択)

  List<MovieInfo> _movieList; // 動画リスト
  String _searchWord; // 検索キーワード
  bool _loading = false; // ローディング状態

・・・略・・・

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: HexColor("1B1B1B"),
      ),
      home: Scaffold(
        appBar: AppBar(
          centerTitle: true,
          leading: Padding(
            padding: EdgeInsets.only(left: 10.0),
            child: Icon(
              Icons.camera,
              color: HexColor("ff9900"),
            ),
          ),
          title: Text(
            "Pornhub Viewer",
            style: GoogleFonts.mPlus1p(),
            textAlign: TextAlign.justify,
          ),
        ),
        body: Container(
            color: Colors.black,
            child: Column(
              children: <Widget>[
                // *** 検索条件入力部 ***
                Row(
                  children: <Widget>[
                    // 表示件数プルダウン
                    DropdownButton<String>(
                        itemHeight: 55,
                        value: _selectDispMaxCount,
                        selectedItemBuilder: (context) {
                          return _dispMaxCountList.map((String item) {
                            return Center(
                              child: SizedBox(
                                height: 20,
                                width: 35,
                                child: Text(
                                  item,
                                  textAlign: TextAlign.center,
                                  style: TextStyle(
                                    color: Colors.white,
                                  ),
                                ),
                              ),
                            );
                          }).toList();
                        },
                        items: _dispMaxCountList.map((String item) {
                          return DropdownMenuItem(
                              value: item,
                              child: Text(
                                item,
                                style: TextStyle(
                                  color: Colors.black,
                                ),
                              ));
                        }).toList(),
                        onChanged: (String newValue) {
                          setState(() {
                            _selectDispMaxCount = newValue;
                          });
                        }),
                    // 検索条件入力蘭
                    Expanded(
                      child: Padding(
                        padding: EdgeInsets.all(3.0),
                        child: Container(
                          height: 40,
                          decoration: new BoxDecoration(
                            color: Colors.white,
                          ),
                          child: TextFormField(
                            decoration: const InputDecoration(
                              icon: Icon(Icons.search),
                              hintText: "input search word.",
                            ),
                            onChanged: (text) {
                              setState(() {
                                _searchWord = text;
                              });
                            },
                          ),
                        ),
                      ),
                    ),
                    // 検索ボタン
                    Container(
                      height: 45,
                      padding: EdgeInsets.all(3),
                      child: RaisedButton(
                          child: Text(
                            "SEARCH",
                            style: TextStyle(color: HexColor("1B1B1B")),
                          ),
                          color: HexColor("ff9900"),
                          shape: RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(5),
                          ),
                          onPressed: _loading
                              ? null
                              : () {
                                  getMovieList(); // 動画リスト取得
                                }),
                    ),
                  ],
                ),
                // *** インジケータ表示部 ***
                _loading ? LinearProgressIndicator() : Container(),
                // *** 動画リスト表示部 ***
                Expanded(
                  child: _movieList == null
                      ? Text(
                          "Please input search word!!",
                          style: TextStyle(color: Colors.white),
                        )
                      : ListView.builder(
                          itemCount: _movieList.length,
                          itemBuilder: (context, index) {
                            return ListTile(
                              contentPadding: EdgeInsets.all(5.0),
                              // 引数にURLを設定
                              onTap: () async {
                                await Navigator.of(context).pushNamed('/movie',
                                    arguments: '${_movieList[index].url}');
                              },
                              // 画像・タイトル表示
                              leading:
                                  Image.network('${_movieList[index].image}'),
                              title: Text(
                                '${_movieList[index].title}',
                                style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 10,
                                ),
                              ),
                              trailing: Padding(
                                padding: EdgeInsets.all(5.0),
                                child: Icon(
                                  Icons.arrow_right,
                                  size: 20,
                                  color: Colors.white,
                                ),
                              ),
                            );
                          },
                        ),
                ),
              ],
            )),
      ),

・・・略・・・

    );
  }
}

・・・略・・・

// カラーコード指定
class HexColor extends Color {
  static int _getColorFromHex(String hexColor) {
    hexColor = hexColor.toUpperCase().replaceAll('#', '');
    if (hexColor.length == 6) {
      hexColor = 'FF' + hexColor;
    }
    return int.parse(hexColor, radix: 16);
  }

  HexColor(final String hexColor) : super(_getColorFromHex(hexColor));
}
↑検索画面表示の実装
マイケル
マイケル
Scaffoldという共通部品の中に、appBar、title、body等枝分かれしていて、
bodyの中にプルダウンやテキストフィールドを並べて・・・
というように、階層を深くしていきながらWidget(※1)を配置していくような実装イメージになります!
(※1)Widget → テキストやボタン、レイアウト等の配置できるコンポーネントのこと。
Screenshot 2020 12 19 12 25 45↑大枠から作成し、枝分かれしながら配置していく
エレキベア
エレキベア
作っていくにつれて階層が深くなって、
コードが読みにくくなりそうクマね
マイケル
マイケル
そこが難点だよね。
作っている時はホットリロードで確認しながらでサクサク作れるけど、作り込むにつれて可読性が悪くなってしまう。
初めのうちはテキストとボタンだけとか、最小限のWidgetを使うような簡単なアプリを作成してみる方が理解が早いかもしれないね。
エレキベア
エレキベア
ここは今後の課題クマね〜〜〜〜
スクレイピング処理
マイケル
マイケル
そして次は動画取得の処理について!
こちらはスクレイピングというテクニックを使って
HTMLから動画一覧を取得しています!
マイケル
マイケル
getMoveList()メソッドに取得処理を記述し、検索ボタン押下時に呼び出すように設定しています。
また、スクレイピング処理を行うため、外部パッケージとして universal_htmlを使用しました!

  // 動画リスト取得処理
  void getMovieList() async {
    // 入力されていない場合は処理なし
    if (_searchWord == null || _searchWord.length == 0) {
      return;
    }

    // ローディング状態にする
    setState(() {
      _loading = true;
    });

    // 検索結果ページを取得
    final url = "https://jp.pornhub.com/video/search?search=" + _searchWord;
    final client = driver.HtmlDriver();
    await client.setDocumentFromUri(Uri.parse(url));

    // 検索件数文章取得
    final searchCountElm = client.document.querySelector(".showingCounter");
    if (searchCountElm == null) {
      // 取得結果無しの場合
      setState(() {
        _loading = false;
      });
      return;
    }

    // 検索結果件数を取得
    int searchCount = 0;
    var regExp = new RegExp('[0-9]{2,}');
    Iterable<Match> matches = regExp.allMatches(searchCountElm.text.trim());
    if (matches.isNotEmpty) {
      searchCount = int.parse(
          matches.last.group(0).toString()); // 検索結果件数:「1~XXを表示中(XXの中から)」から抽出

    }
    // 最大表示件数以上の場合、最大表示件数を設定
    if (searchCount > int.parse(_selectDispMaxCount)) {
      searchCount = int.parse(_selectDispMaxCount);
    }
    // 表示ページ数取得
    int dispPageCount = (searchCount / 20).ceil(); // ページ数:検索結果件数 / 20件 (繰上)

    // 検索結果のリスト取得
    List<MovieInfo> tmpList = new List<MovieInfo>();
    for (var i = 0; i < dispPageCount; i++) {
      // 2ページ目以降の場合、URLを再取得
      if (i > 0) {
        final nextUrl = url + "&page=" + (i + 1).toString();
        await client.setDocumentFromUri(Uri.parse(nextUrl));
      }

      // 動画情報の一覧を取得
      final elements = client.document
          .querySelectorAll("#videoSearchResult > .pcVideoListItem");

      // タイトル、画像、URLを取得
      for (final elem in elements) {
        final titleElm = elem.querySelector(".title");
        final imageElm = elem
            .querySelector(".videoPreviewBg > img")
            .getAttribute('data-src');
        final urlElm = "https://jp.pornhub.com" +
            elem.querySelector("a").getAttribute('href');
        tmpList.add(new MovieInfo(titleElm.text.trim(), urlElm, imageElm));
      }
    }

    // 取得した動画リストを設定
    setState(() {
      _movieList = tmpList;
      _loading = false;
    });
  }

・・・略・・・

// 動画情報クラス
class MovieInfo {
  final String title, url, image;
  MovieInfo(this.title, this.url, this.image);
}

↑スクレイピング処理
マイケル
マイケル
入力した _searchWord 変数から、検索結果URLを作成し、
querySelector()や正規表現を駆使して動画情報を取得しています!
マイケル
マイケル
スクレイピングの詳細については省略させていただきますが、
実際にWEBサイトを開いて
開発者ツールでIDやクラス名を確認したり
検索条件を入力した挙動や、画面遷移後のURLなどを観察
しながら、ゴリゴリと実装していきましょう!
エレキベア
エレキベア
トライアンドエラーで地道に作っていくクマね
マイケル
マイケル
あとはWEBサイトの更新により構造自体が変わってしまうということもあるので、
IDで取得する等、なるべく一意に取得できるようにするのが望ましいですね・・・。
マイケル
マイケル
とはいえこればかりはWEBサイトの構造にもよるので、
メンテは必要だという覚悟で実装しましょう!!
エレキベア
エレキベア
汗と涙の結晶クマーー・・・。
内部ブラウザ表示
マイケル
マイケル
そして最後は選択した動画の内部ブラウザ表示処理です!
外部パッケージとしては、
ページ遷移のアニメーションに page_transition、
内部ブラウザの表示に webview_flutter

を使用しました!


・・・略・・・

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primaryColor: HexColor("1B1B1B"),
      ),

・・・略・・・

        body: Container(
            color: Colors.black,
            child: Column(
              children: <Widget>[
                // *** 検索条件入力部 ***
                Row(
                  children: <Widget>[

・・・略・・・

                // *** 動画リスト表示部 ***
                Expanded(
                  child: _movieList == null
                      ? Text(
                          "Please input search word!!",
                          style: TextStyle(color: Colors.white),
                        )
                      : ListView.builder(
                          itemCount: _movieList.length,
                          itemBuilder: (context, index) {
                            return ListTile(
                              contentPadding: EdgeInsets.all(5.0),
                              // 引数にURLを設定
                              onTap: () async {
                                await Navigator.of(context).pushNamed('/movie',
                                    arguments: '${_movieList[index].url}');
                              },

・・・略・・・

                ),
              ],
            )),
      ),
      // 画面遷移設定
      // ※PageTransitionを使用
      onGenerateRoute: (settings) {
        switch (settings.name) {
          case '/movie':
            return PageTransition(
                child: VideoPage(),
                type: PageTransitionType.rightToLeftWithFade,
                settings: settings);
            break;
          default:
            return null;
        }
      },
    );
  }
}

・・・略・・・

// 動画情報クラス
class MovieInfo {
  final String title, url, image;
  MovieInfo(this.title, this.url, this.image);
}


↑画面遷移処理
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:google_fonts/google_fonts.dart';

class VideoPage extends StatefulWidget {
  @override
  VideoPageState createState() => VideoPageState();
}

class VideoPageState extends State<VideoPage> {
  WebViewController _controller;

  @override
  void initState() {
    super.initState();
    // Enable hybrid composition.
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  Widget build(BuildContext context) {
    // 遷移元よりURLを取得
    final String movieUrl = ModalRoute.of(context).settings.arguments;
    print("Request URL:" + movieUrl);

    return Scaffold(
      appBar: AppBar(
        title: Text(
          "Good Choice Σd(T□T)",
          style: GoogleFonts.mPlus1p(),
        ),
      ),
      body: WebView(
        initialUrl: movieUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController controller) {
          _controller = controller;
        },
      ),
    );
  }
}
↑遷移元のパラメタよりURL取得
マイケル
マイケル
動画リストの onTap() イベントでタップした動画URLを遷移パラメタとして
動画画面に渡す
動きにしています。
あとは動画画面で 受け取ったURLからWebViewを使用して内部ブラウザで表示 するように実装すれば完成です!!
エレキベア
エレキベア
やったクマ〜〜〜〜〜
スポンサーリンク

おわりに

マイケル
マイケル
・・・・!!!!
マイケル
マイケル
なかなかストレスフリーで使いやすいな〜〜〜〜
エレキベア
エレキベア
夜のお供として活躍しそうクマね
マイケル
マイケル
今回作ったアプリは、アダルトサイトだけでなく
様々なサイトの情報取得にも応用出来ると思うので、
是非活用してみてくださいね!
エレキベア
エレキベア
クマ〜〜〜〜〜〜〜〜
ジャギィ
ジャギィ
お、俺のスマホにもインストールしてくれ!!!
マイケル
マイケル
(ウイルス仕込んだろ・・・。)

【Flutter】自給自足!Pornhubの検索アプリを作ってみた!【R18】 〜完〜

コメント

  1. もつおむらむら より:

    一番最初にするpubspec.yamlに記述し、pub getして取り込む手順を知りたい。
    この記事を見つけて、flutterを始めたものです。
    プログラミングはおろか、パソコン自体の使い方もよくわかってない初心者なので、分かりやすい手順を知りたいです。専門用語言われても分かりません。ですが、どうしても作りたいのです

    • マイケル マイケル より:

      コメントありがとうございます!
      外部パッケージに関しては、pubspec.yamlに記述して保存すると自動でダウンロードされますが、
      反応しない場合は手動で「flutter packages get」コマンドを叩くと取得されるかと思います!
      参考:https://flutter.ctrnost.com/tutorial/tutorial04/

      ちなみにFlutterの環境構築はお済みでしょうか?
      もしまだでしたら、下記にFlutter公式の環境構築、チュートリアルのページを貼っておきますので
      こちらで環境構築からアプリ作成まで試していただけるとイメージが掴めると思います(`・ω・´)

      ・環境構築
      https://flutter.ctrnost.com/install/
      ・チュートリアル
      https://flutter.ctrnost.com/tutorial/

      初めてだと環境構築も一苦労だと思いますので、もし不明点などあれば気軽にご質問ください!

  2. もつおむらむら より:

    直接「flutter packages get」コマンドを叩いたのですが、「Running “flutter pub get”in study・・・490ms」と表示されたのですが、これは「490分待て」ということなんでしょうか。。

    • マイケル マイケル より:

      そちらは「flutter pub get」の実行に490ミリ秒かかったという意味です!
      特にエラーが出てないようであれば問題ないかと思います(`・ω・´)

  3. もつおむらむら より:

    Target of URI doesn’t exist: ‘package:porn_clone/video_page.dart’.
    Target of URI doesn’t exist: ‘package:universal_html/driver.dart’.
    Non-nullable instance field ‘_movieList’ must be initialized.
    Non-nullable instance field ‘_searchWord’ must be initialized.
    The default ‘List’ constructor isn’t available when null safety is enabled.
    The argument type ‘void Function(String)’ can’t be assigned to the parameter type ‘void Function(String?)?’.
    The method ‘VideoPage’ isn’t defined for the type ‘MainPageState’.

    連投質問申し訳ありません。main.dartで上記の6つのエラーが表示されます。
    またvideo_page.dartでは下記の3つのエラーが表示されます。。

    Non-nullable instance field ‘_controller’ must be initialized.
    A value of type ‘Object?’ can’t be assigned to a variable of type ‘String’.
    The property ‘settings’ can’t be unconditionally accessed because the receiver can be ‘null’.

    我慢が出来ず、FANZAで1万分の動画を購入しましたが、、、このアプリはどうしても諦められません。

  4. もつおむらむら より:

    video_page.dateとuniversal.htmlがtarget of URI does’nt existとなります。

    先ほど二回ほどコメントしたのですが、コメントが表示されないです。届いてますでしょうか・・・
    どうしても作りたくてFANZAで1万円分の動画購入しました。我慢が出来ません

    • マイケル マイケル より:

      申し訳ありません、気付くのが遅くなりメッセージ承認が遅れてしまいました・・・!
      エラー内容について調査してみますので、しばしお待ちくださいm(_ _)m

    • マイケル マイケル より:

      調べたところ、どうやらFlutterの最新バージョンに合わせて「Null Safety」に対応したパッケージに更新する必要があるようでした。
      参考:https://zuma-lab.com/posts/flutter-dart-sound-null-safety-replace

      「dart pub outdated –mode=null-safety」でアップグレードができるようなので、お試しいただけないでしょうか?
      コマンドがうまく実行できなければ、pubspec.yaml内のパッケージのバージョンを下記に変更してみてください!
      page_transition: ^2.0.2
      webview_flutter: ^2.0.9
      google_fonts: ^2.1.0
      universal_html: ^1.1.16

      こちらの内容は記事に追記させていただこうと思います。
      お手数おかけしますがよろしくお願いしますm(_ _)m!