Flutter 小说爬虫Demo

5,258 阅读4分钟

Flutter 尽管没有 Python 那样有强大的爬虫框架,但本身自己却是有一套自己解析 HTML5 语言的插件。我们可以用他解析一些网页,也算是简易版的爬虫吧。

我就用笔趣阁的网站介绍一下这个框架,我们需要在 pubspec.yaml 中添加引用:

html: ^0.14.0+3

分析网站

以笔趣阁搜索"元尊"为例。

从上图中,我们可以看出,很多信息,我们想要爬取的网站域名,关键字,以及网址。

  • 域名
static const String baseImgUrl = "http://www.xbiqige.com";
static const String baseUrl = "http://www.xbiqige.com/";
  • 网址
 //搜索小说的接口
static const searchBook = "search.html?searchtype=novelname&searchkey=";

  • 我们要展示的实体类信息

假如我们要在 App 中显示网页搜索条目结果,我们可以根据网页上的信息构造我们自己的实体类。

class BookSearchItem {
  //书名
  final String bookName;

  //图书地址
  final String bookUrl;
  //作者
  final String author;

  //最新章节url
  final String lastUrl;

  //最新章节标题
  final String lastTitle;

  //文章类型
  final String type;

  //图书封面
  final String bookCover;

  BookSearchItem(
      this.author, this.lastUrl, this.lastTitle, this.type, this.bookCover, this.bookName, this.bookUrl);

  @override
  String toString() {
    return 'BookSearchItem{bookName: $bookName, bookUrl: $bookUrl, author: $author, lastUrl: $lastUrl, lastTitle: $lastTitle, type: $type, bookCover: $bookCover}';
  }
}

解析网页

鼠标右键,查看源码,书读百遍其义自见,就一直看,虽然不能背下来,但也能看出一点端倪吧,嘿嘿,说不定你就把它背下来了。。。

网络请求类

万丈高楼,始于基地,数据的请求一直都是基层工作。下面构造我们的网络请求类。

请记得引入 dio 框架。


import 'package:dio/dio.dart';

class DioFactory {
  static DioFactory get instance => _getInstance();

  static DioFactory _instance;

  Dio _dio;

  BaseOptions _baseOptions;

  DioFactory._internal(
      {String basUrl = Config.baseUrl,
      Map<String, dynamic> header = Config.headers}) {
    _baseOptions = new BaseOptions(
      baseUrl: basUrl,
      connectTimeout: Config.connectTimeout,
      responseType: ResponseType.json,
      receiveTimeout: Config.receiveTimeout,
      //headers: header
    );
    _dio = new Dio(_baseOptions);
  }

  static _getInstance() {
    if (null == _instance) {
      _instance = new DioFactory._internal();
    }

    return _instance;
  }


  /**
   * 新添加,只用于返回字符串,而不是map类型
   *
   */
  Future<String> getString(url, {options, cancelToken, data}) async {
    print("get==>:$url,body:$data");

    Response response;
    try {
      response = await _dio.get(url, cancelToken: cancelToken);
    } on DioError catch (e) {
      if (CancelToken.isCancel(e)) {
        print('get请求取消! ' + e.message);
      } else {
        print('get请求发生错误:$e');
      }
    }
    //print(response.data.toString());
    return response == null ? "" : response.data.toString();
  }

}

class Config {
  static const String baseImgUrl = "http://www.xbiqige.com";
  static const String baseUrl = "http://www.xbiqige.com/";

  ///链接超时时间
  static const int connectTimeout = 8000;

  ///  响应流上前后两次接受到数据的间隔,单位为毫秒。如果两次间隔超过[receiveTimeout],
  ///  [Dio] 将会抛出一个[DioErrorType.RECEIVE_TIMEOUT]的异常.
  ///  注意: 这并不是接收数据的总时限.
  static const int receiveTimeout = 3000;

  ///普通格式的header
  static const Map<String, dynamic> headers = {
    "Accept": "application/json",
  };

  ///json格式的header
  static const Map<String, dynamic> headersJson = {
    "Accept": "application/json",
    "Content-Type": "application/json; charset=UTF-8",
  };
}

html 插件解析代码

我们首先应该分析一下页面上的代码,看有没有什么逻辑可循。

 //搜索书籍的接口,为List赋值
  Future<List<BookSearchItem>> fetchSearchBook(String bookName) async {
    var response;
    List<BookSearchItem> books = new List();
    try {
      //获取到整个网页的代码数据
      response = await net.getString("${StringApi.searchBook}$bookName");
      var document = parse(response);//将String 转换为document对象
      var content = document.querySelector(".librarylist");//找到标签中librarylist的节点,类选择器节点的查找前面要加个.
      var lefts = content.querySelectorAll(".pt-ll-l");//找到所有pt-ll-l节点
      var rights = content.querySelectorAll(".pt-ll-r");//找到所有pt-ll-r 节点
      int count = lefts.length > rights.length ? rights.length : lefts.length;//取最短数据,这里是为了保险,防止数组越界
      for (int i = 0; i < count; i++) {
        //在pt-ll-l,pt-ll-r 节点下找到目标数据
        BookSearchItem item = new BookSearchItem(
            rights[i].querySelectorAll(".info>span")[1].text.trim(),//第二个span元素值,获取作者
            rights[i].querySelector(".last>a").attributes["href"].trim(),//href 属性值,最后一章的Url
            rights[i].querySelector(".last>a").text.trim(),//元素值,获取标题
            rights[i].querySelectorAll(".info>span")[2].text.trim(),//第三个span元素值,获取小说分类
            lefts[i].querySelector("div>a>img").attributes['src'].trim(),//获取小说图片
            lefts[i].querySelector("div>a>img").attributes['alt'].trim(),//获取书名
            lefts[i].querySelector("div>a").attributes['href'].trim());//获取书URL
        books.add(item);
        print(item.toString());
      }
      return books;
    } catch (e) {
      print(e);
    }

    return books;
  }

页面展示

class DemoBiqugePage extends StatefulWidget {
  @override
  _DemoBiqugePageState createState() => _DemoBiqugePageState();
}

class _DemoBiqugePageState extends State<DemoBiqugePage> {
  Api _api = new Api();

  List<BookSearchItem> _books = new List();

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    _api.fetchSearchBook("元尊").then((data) {
      setState(() {
        _books.clear();
        _books.addAll(data);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("笔趣阁爬虫示例"),
      ),
      body: _books.length == 0
          ? Center(
              child: Text("正在加载数据..."),
            )
          : Container(
              margin: EdgeInsets.all(10),
              child: ListView.separated(
                  itemBuilder: (BuildContext context, int index) {
                    return item_book_search(context, _books[index]);
                  },
                  separatorBuilder: (BuildContext context, int index) {
                    return Divider(
                      height: 2,
                      color: Theme.of(context).primaryColor,
                    );
                  },
                  itemCount: _books.length),
            ),
    );
  }
}

Widget item_book_search(BuildContext context, BookSearchItem book) {
  return Container(
      margin: EdgeInsets.only(left: 10, right: 10, top: 10),
      child: InkWell(
        borderRadius: BorderRadius.circular(20),
        onTap: () {},
        child: Container(
          child: Container(
              height: 140,
              margin: EdgeInsets.all(10),
              child: Row(
                children: <Widget>[
                  Container(
                    height: 120,
                    width: 80,
                    child: CachedNetworkImage(
                        imageUrl: Config.baseImgUrl + book.bookCover),
                  ),
                  Expanded(
                      child: Container(
                    margin: EdgeInsets.only(left: 30),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: <Widget>[
                        Container(
                          margin: EdgeInsets.only(bottom: 20),
                          child: Text(
                            book.bookName,
                            style: TextStyle(
                                fontWeight: FontWeight.w500,
                                color: Colors.black,
                                fontSize: 18.0),
                            overflow: TextOverflow.ellipsis,
                            maxLines: 1,
                          ),
                        ),
                        Row(
                          children: <Widget>[
                            Container(
                              margin: EdgeInsets.only(top: 0),
                              child: Text(
                                "类型:" + book.type.split(':')[1],
                                style: TextStyle(
                                    fontWeight: FontWeight.w300,
                                    color: Colors.black,
                                    fontSize: 12.0),
                                overflow: TextOverflow.ellipsis,
                                maxLines: 1,
                              ),
                            ),
                            Container(
                              margin: EdgeInsets.only(left: 20),
                              child: Text(
                                book.author,
                                style: TextStyle(
                                    fontWeight: FontWeight.w300,
                                    color: Colors.black,
                                    fontSize: 12.0),
                                overflow: TextOverflow.ellipsis,
                                maxLines: 2,
                              ),
                            )
                          ],
                        ),
                        Container(
                          margin: EdgeInsets.only(top: 20),
                          child: Text(
                            book.lastTitle,
                            style: TextStyle(
                                fontWeight: FontWeight.w400,
                                color: Colors.black,
                                fontSize: 16.0),
                            overflow: TextOverflow.ellipsis,
                            maxLines: 2,
                          ),
                        ),
                      ],
                    ),
                  ))
                ],
              )),
        ),
      ));
}

效果图

总结

以上用到的插件有:

  dio: ^2.1.10
  cached_network_image: ^2.0.0
  html: ^0.14.0+3

以上的代码直接复制,粘贴到自己的 Demo 中即可运行,如果你对比着笔趣阁网站页面的 H5 代码看,效果会更好。Flutter 的解析框架肯定没有 Python 的强大,但是用来用来匹配字符串的最要还是正则表达式,要从茫茫码海中提取出自己想要的数据,还是不容易。且行且珍惜。