首发于python学习
Flask的小说网站真的不更新篇

Flask的小说网站真的不更新篇

前言

专栏有很长时间没有更新了,主要是自己在不久前建了个个人博客,用的还算不错。再者就是在逼乎上写的几篇文章,现在看着真的是辣眼睛,之前实现的代码跟屎一样。

本来之前写的一个通过爬虫爬取顶点网,然后利用flask做web页面,已经结束了。我也没有打算后面继续更新了,但是最近似乎顶点网做了一次大的更新,之前的代码不能使用了。这几天打开逼乎收到大概四五条私信,问我为什么代码出问题。然后我看github上面之前的代码,真是恶心,这样的代码真对不起认真阅读的伙伴。然后就都被我删了,准备重新实现。然后我就又过来做一个了结。

大致路程

言归正传,我们要实现的效果和之前的是一样的,也就是通过用户输入要搜索的书名,然后开启爬虫抓取对应的信息,然后展示在页面中。那么我们的实现流程如下:

  1. 用户输入搜索关键词,比如"诛仙";
  2. 调用爬虫抓取结果页的api,抓取到数据,保存数据库;
  3. 展示搜索结果页,如果关键词在数据库中有相同的,就直接调用数据库,如果没有则调用爬虫抓取(第二步);
  4. 用户点击一个结果,比如"诛仙";
  5. 调用爬虫抓取章节页的api,抓取所有章节数据,保存数据库;
  6. 展示章节页,如果小说在数据库中有相同的键,就直接调用数据库,如果没有则调用爬虫抓取(第五步);
  7. 用户点击某一章节,比如第一章;
  8. 调用爬虫抓取对应章节内容的api,抓取章节内容,保存数据库;
  9. 展示章节内容页,如果章节在数据库中有相同的键,就直接调用数据库,如果没有则调用爬虫抓取(第八步)。

爬虫实现

首先先来看一下图:

图中红色的框中就是结果页的URL,我们只要调用爬虫去抓取这一页就可以了。那么我们可以看出url中的q参数的值就是我们搜索的小说名,而p就是页数,大家可以自行点击下一页或者自己搜索看看。

写爬虫

由于顶点页比较简单,我们直接使用正则去匹配到我们的数据就可以了。如果大家对正则不熟悉可以使用解析库BeautifulSoup,Xpath之类的,只不过正则会好写很多。

# 先导入模块
import re
import requests
from requests.exceptions import ConnectionError

# 请求网页函数
def parse_url(url):
    headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0'}
    try:
        resp = requests.get(url, headers=headers)
        if resp.status_code == 200:
            # 处理一下网站打印出来中文乱码的问题
            resp.encoding = 'utf-8'
            return resp.text
        return None
    except ConnectionError:
        print('Error.')
    return None

利用Requests库请求网页的函数,方便后面直接调用。由于顶点网在我爬取的时候会出现网页的乱码,我们做一些简单的编码处理就好了。

# 搜索结果页数据
def get_index_result(search):
    # 构成结果页的url
    url = 'http://zhannei.baidu.com/cse/search?q={search}&p={page}&s=1682272515249779940&entry=1'.format(search=search, page=0)
    # 调用请求函数
    resp = parse_url(url)
    # 调用正则的sub方法,去掉网页response中强调搜索关键词的em标签
    resp = re.sub(r'<em>', '', resp)
    resp = re.sub(r'</em>', '', resp)
    # 正则抓取数据
    p = re.compile(r'<a cpos="img".*?<img src="(.*?)".*?<a cpos="title".*?="(.*?)".*?_blank">'
                   + r'(.*?)</a>.*?<p class="result-game-item-desc">(.*?)'
                   + r'</p>.*?<span.*?<span>(.*?)</span>.*?title">(.*?)</span>.*?title">(.*?)</span>', re.S)
    items = re.findall(p, resp)
    for i in items:
        # 组合成字典
        data = {
            'image': i[0],
            'url': i[1],
            'title': i[2].strip(),
            'profile': i[3].strip().replace('\u3000', '').replace('\n', ''),
            'author': i[4].strip(),
            'style': i[5].strip(),
            'time': i[6].strip()
        }
        yield data

抓取结果页的函数,我们将结果组合成字典,使用yield语法糖,每次调用返回一个结果。

这里我们抓取了小说的封面图片,小说的url,小说名,小说作者,小说简介,小说风格,小最后更新时间。大家可以打印测试,由于篇幅可能会比较大,代码的测试大家自己去完成,我就不展示了。

抓取到搜索结果,那下一步自然就是抓取小说对应的章节了。我们随便点一本小说,发现章节页是没有分页的,这样的设计很蠢但是很方便我们爬取。相信大家对于这种简单的网页抓取起来很轻松,我就直接上代码了。

# 小说章节页数据
def get_chapter(url):
    resp = parse_url(url)
    p = re.compile(r'<dd> <a style=.*?href="(.*?)">(.*?)</a></dd>')
    chapters = re.findall(p, resp)
    for i in chapters:
        data = {
            'url': str(url) + i[0],
            'chapter': i[1]
        }
        yield data

相同的方法,我们抓取到章节的url,这里我们抓取到的url并不是完整的,我们自己组合一下。

下一步就是抓取章节的内容了。

# 章节内容页数据
def get_article(url):
    resp = parse_url(url)
    p = re.compile(r'<div id="content">(.*?)</div>', re.S)
    article = re.findall(p, resp)
    # 文章中的'<br/>'标签先不去除,后面在模版中使用
    return article[0].strip()

注释中说了网页正则匹配得到的标签并没有直接过滤掉,这样我们后面在jinja2模版中展示直接让他不去过滤标签就可以了。

那么这样我们的爬虫篇就实现了,我们为了方便调用,我们将他们模块化,定义一个DdSpider类。把上面代码组合在一起,不需要多大的改动,就可以了。如果大家有疑问可以直接看我的源码,github地址文末会给出。

Flask展示

首先我们整理一下我们的项目框架:

dingdian/
    dingdian/
        main/
            __init__.py
            views.py
            forms.py
        spider/
            spider.py
        templates/
        static/
        __init__.py
        models.py
    config.py
    manage.py
    requirements.txt

我们首先将一些配置写到配置文件中去:

# config.py
# coding=utf-8
import os
basedir = os.path.abspath(os.path.dirname(__file__))

# wtf表单配置
CSRF_ENABLED = True
SECRET_KEY = 'you-guess'
# 数据库配置
SQLALCHEMY_COMMIT_ON_TEARDOWN = True
SQLALCHEMY_TRACK_MODIFICATIONS = False

SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
                              'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')
DEBUG = True
# 章节页每一页显示的章节数
CHAPTER_PER_PAGE = 20

知乎这编辑器有问题吧,用的跟弱智一样。我是直接将后面的所有配置都写出来了,并没有一步一步的往里面加,大家明白就好了。

这里我我们使用的是flask集成的SQLAlchemy关系型数据库,我们需要安装扩展:

$ pip install flask-aqlalchemy

我们将数据库模型先写出来。首先我们要考虑我们的数据对应关系,怎么实现起来方便我们使用。

首先我们应该有一个Search模型,这个模型存放搜索的关键词,和搜索的结果小说数据。那么我们肯定的又一个存放我们小说数据的Novel模型,这里面存放我们结果页的信息,比如小说名,url,封面图片,作者,简介等,还要存放搜索关键词,是哪个关键词搜到的他。由于多对多实现起来麻烦一点,我们这里选择一对多关系,即搜索关键词对多个小说。那么Search模型和Novel模型是一对多关系,我们实现一下。

不过在实现之前,我们应该写一个工厂函数,用来创建我们的程序实例,这样做可以使我们的项目架构更加清晰,我们的工厂函数存放在顶点的__init__文件:

# dingdian/dingdian/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

import config

db = SQLAlchemy()

def create_app():
    app = Flask(__name__)
    # 我们使用app.config对象提供的from_object()方法导入配置
    app.config.from_object(config)
    # 注册数据库实例
    db.init_app(app)
    # 构造蓝本
    from .main.views import main as main_blueprint
    app.register_blueprint(main_blueprint)

    return app

由于用来工厂函数,那么我们的程序只有调用工厂函数creat_app()后才能使用app.route装饰器,那么我们使用蓝本,将蓝本注册到程序中,这是路由就成了程序的一部分了。

模型实现如下:

# 导入数据库实例
from dingdian import db

# Search模型
class Search(db.Model):
    # 设置表的名字
    __tablename__ = 'searches'
    id = db.Column(db.Integer, primary_key=True)
    search_name = db.Column(db.String(64), index=True)
    # db.relationship的第一个参数说明另一端的模型是哪个,
    # backref的值是向另一端的模型加一个属性,反向应用
    # lazy指定joined加载记录但是使用联结
    novels = db.relationship('Novel', backref='search', lazy='joined')

# Novel模型
class Novel(db.Model):
    __tablename__ = 'novels'
    id = db.Column(db.Integer, primary_key=True)
    book_name = db.Column(db.String(64), index=True)
    book_url = db.Column(db.String)
    book_img = db.Column(db.String)
    author = db.Column(db.String(64))
    style = db.Column(db.String(64), nullable=True)
    last_update = db.Column(db.String(64), nullable=True)
    profile = db.Column(db.Text, nullable=True)
    # db.ForeignKey的参数表名这一列的值是哪个表的那一个属性
    search_name = db.Column(db.String, db.ForeignKey('searches.search_name'))

SQLAlchemy列类型:

  • Integer:普通整数,一般是32位,python类型是int
  • String:字符串,python类型是str
  • Text:也是字符串,对较长不限长度字符串做了优化

SQLAlchemy列选项:

  • primary_key:如果为True,那么这列是表的主键
  • unique:为True的话,这列不允许有重复值
  • index:如果为True,那么这里创建索引,加速查询
  • nullable:如果为True,这列可以出现空值,默认为Flase

代码中有注释,相信大家可以理解。那么这就是一对多关系的实现。

通过爬虫的实现,我们肯定还需要章节模型Chapter和文章内容模型Article。我们一一实现。

那么很显然,Chapter和Novel是多对一的关系(也就是一对多)。Chapter和Article是一对一关系。参照上面的实现,实现起来并不难,为了让Novel和Chapter联结对应,我们要在Novel模型中加上:

chapters = db.relationship('Chapter', backref='book', lazy='dynamic')

Chapter和Article模型:

class Chapter(db.Model):
    __tablename__ = 'chapters'
    id = db.Column(db.Integer, primary_key=True)
    chapter = db.Column(db.String(64))
    chapter_url = db.Column(db.String, index=True)

    article = db.relationship('Article', backref='chapter', lazy='dynamic')
    book_id = db.Column(db.Integer, db.ForeignKey('novels.id'))


class Article(db.Model):
    __tablename__ = 'articles'
    id = db.Column(db.Integer, primary_key=True)
    content = db.Column(db.Text)

    chapter_id = db.Column(db.Integer, db.ForeignKey('chapters.id'))

接下来就要考虑视图函数的实现了。因为我们用户要搜索内容,那肯定需要一个表单,表单的实现呢,我们需要安装flask-wtf扩展:

$ pip install flask-wtf

当然其实自己写一个表单也是可以的,而且不需要配置比如CSRF,密钥这些信息。但是如果我们写一个大的项目,或者写一个个人博客的时候,自己实现表单就需要考虑到表单防止跨站的攻击了。

接下来写一个表单类:

# main/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField
from wtforms.validators import DataRequired


class SearchForm(FlaskForm):
    search_name = StringField('search', validators=[DataRequired()])
    submit = SubmitField('submit')

表单的实现还是很容易懂得:

WTForms的HTML字段:

  • StringField:文本字段
  • SubmitField:表单提交按钮

验证函数:

  • DataRequired:字段中必须有内容,否则弹出错误

表单写好了,那么我们就好考虑视图函数的实现了。首先我们的首页就是我们的搜索界面,用户输入内容。我们就把结果展示在结果页。用户点击小说,我们就把章节数据展示在章节页。用户点击某个章节,我们就在内容页展示内容。基本的逻辑还是很清晰的,我们先来实现首页的始于函数:

# main/views.py
from flask import flash, render_template, url_for, redirect, request, current_app
from flask.blueprints import Blueprint

from dingdian import db
from .forms import SearchForm
from ..spider.spider import DdSpider
from ..models import Search, Novel, Chapter, Article


main = Blueprint('main', __name__)

我们先导入需要的库,和爬虫类,表单类和数据库模型。下面一行代码就注册了我们的蓝本。

@main.route('/', methods=['GET', 'POST'])
@main.route('/index', methods=['GET', 'POST'])
def index():
    form = SearchForm()
    if form.validate_on_submit():
        search = form.search_name.data
        data = Search(search_name=search)
        db.session.add(data)
        flash('搜索成功。')
        return redirect(url_for('main.result', search=search))
    return render_template('index.html', form=form)

这一步我们做了哪些事情呢?首先我们声明路由,由于有表单我们设置post请求,我们加入methods参数。

实例化表单类,validate_on_submit()方法用来确认表单中的验证函数是否全部通过,通过就返回True,否则False。

然后我们通过form.search_name.data拿到search_name表单中的信息,然后将搜索的表单关键词保存数据库,redirect是flask的url跳转方法,他配合url_for使用,将页面跳转到结果页。然后flash显示搜索成功字样。render_template()方法将参数信息传给模版文件。其他的不需要我多说,如果连flask的基本使用都不知道,那。。。

接下来就是结果也的视图函数:

@main.route('/results/<search>')
def result(search):
    # 查找数据库中search键相等的结果,如果有则不需要调用爬虫,直接返回
    books = Novel.query.filter_by(search_name=search).all()
    if books:
        return render_template('result.html', search=search, books=books)

    spider = DdSpider()
    for data in spider.get_index_result(search):
        novel = Novel(book_name=data['title'],
                      book_url=data['url'],
                      book_img=data['image'],
                      author=data['author'],
                      style=data['style'],
                      profile=data['profile'],
                      last_update=data['time'],
                      search_name=search)
        db.session.add(novel)
    books = Novel.query.filter_by(search_name=search).all()
    return render_template('result.html', search=search, books=books)

这一步我们先查询数据库中是否有搜索的关键词键,filter_by()方法是判断是否存在这一列,all()返回所有数据。

如果有则直接返回数据,如果没有就调用爬虫。我们先实例化爬虫类,调用抓取结果的爬虫api,将信息保存到数据库,传给模版。

后面的章节展示和内容展示也没有什么多说的,先判断数据库里面是不是有这个数据,r如果有则返回,如果没有则调用爬虫抓取。

@main.route('/chapter/<int:book_id>')
def chapter(book_id):
    page = request.args.get('page', 1, type=int)
    all_chapter = Chapter.query.filter_by(book_id=book_id).first()
    # print(type(pagination))
    if all_chapter:
        pagination = Chapter.query.filter_by(book_id=book_id).paginate(
                page, per_page=current_app.config['CHAPTER_PER_PAGE'],
                error_out=False
        )
        chapters = pagination.items
        book = Novel.query.filter_by(id=book_id).first()
        return render_template('chapter.html', book=book, chapters=chapters, pagination=pagination)

    spider = DdSpider()
    book = Novel.query.filter_by(id=book_id).first()
    for data in spider.get_chapter(book.book_url):
        chapter = Chapter(chapter=data['chapter'],
                           chapter_url=data['url'],
                           book_id=book_id)
        db.session.add(chapter)
    pagination2 = Chapter.query.filter_by(book_id=book_id).paginate(
        page, per_page=current_app.config['CHAPTER_PER_PAGE'],
        error_out=False
    )
    chapters = pagination2.items

    return render_template('chapter.html', book=book, chapters=chapters, pagination=pagination2)

@main.route('/content/<int:chapter_id>')
def content(chapter_id):
    book_id = Chapter.query.filter_by(id=chapter_id).first().book_id
    article = Article.query.filter_by(chapter_id=chapter_id).first()
    if article:
        chapter = Chapter.query.filter_by(id=chapter_id).first()
        return render_template('article.html', chapter=chapter, article=article, book_id=book_id)

    spider = DdSpider()
    chapter = Chapter.query.filter_by(id=chapter_id).first()
    article2 = Article(content=spider.get_article(chapter.chapter_url),
                      chapter_id=chapter_id)
    db.session.add(article2)
    return render_template('article.html', chapter=chapter, article=article2, book_id=book_id)

在章节页的视图函数中,我们要调用paginate()方法,他接收第一个参数是必须的参数。page是利用request.args获取请求头中的页码。per_page指定每一页显示的数目,我们已经将信息保存在配置文件。error_out参数设为True,那么如果请求的页数超出了范围,则返回404错误。

接下 来就是模版文件的实现了。

首先,我们写一个继承模版,base.html。我们先不去考虑模版的美观与否,我们先实现功能,那么对于jinja2的语法,if,for,with等我不去多余赘述,大家自行了解:

templates/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    {% block head %}
    <title>{% block title %}My Novel{% endblock %}</title>
    {% endblock %}
</head>
<body>
    <div>
    {% block content %}
    {% endblock %}
    </div>
</body>
</html>

首页模版继承它,首页一个搜索表单index.html:

{% extends "base.html" %}

{% block title %}
首页
{% endblock %}

{% block content %}
<h3 align="center">搜索</h3><hr>
<form action="" method="post" name="search">
{{form.hidden_tag()}}
{{form.search_name(size=80,placeholder="搜索")}}
{% for error in form.search_name.errors %}
    <span style="color: red;">[{{error}}]</span>
{% endfor %}<br>
{{form.submit(value="搜索")}}
</form>
{% endblock %}

我们通过jinja2的for循环,将wtf表单错误显示出来。

接下来是结果页result.html:

{% extends "base.html" %}

{% block title %}
搜索结果
{% endblock %}

{% block content %}
<h3 align="center">{{search}}的搜索结果</h3><hr>
{% for i in books %}
<ul>
    <li>
        <table>
            <tr>
                <td>
                    <img class="img" src="{{i.book_img}}">
                </td>
                <td>
                    <a href="{{url_for('main.chapter',book_id=i.id)}}">{{i.book_name}}</a>
                    <p><b>简介:</b>{{i.profile | safe}}</p>
                    <p><b>作者:</b>{{i.author}}</p>
                    <p><b>类型:</b>{{i.style}}</p>
                    <p><b>更新时间:</b>{{i.last_update}}</p>
                </td>
            </tr>
        </table>
    </li>
</ul>
{% endfor %}
{% endblock %}

后续的模版文件我也不去展示代码了,也没什么好说的,基本的流程前面说了,视图函数也实现了,要的就是一些模版的美化和一些细节了。

比如我在章节页加了一个换回搜索页的连接按钮,在文章内容也加了下一章和上一章以及返回目录按钮,在屏幕边缘加了回到顶部,底部的按钮。这些大家可以根据自己的喜好自己拓展,也可以直接copy我的实现。

那么启动项目,我们仍然要安装flask的一个扩展:

$ pip install flask-migrate

有了这个模块,他提供了一个MigrateCommand类,可以附加到flask-script对象上,可以让我们对数据库的迁移实现的很方便。只需要一行代码:

$ python manage.py db init

我们还要安装一个flask-script模块,他给我们提供了一个Manager的类,我们就可以让服务器由manager.run()启动,就能解析命令行了。那么我们的启动程序如下:

from flask_script import Manager
from flask_migrate import Migrate, MigrateCommand
from dingdian import db
from dingdian import create_app


app = create_app()
manager = Manager(app)
migrate = Migrate(app, db)
manager.add_command('db', MigrateCommand)

if __name__ == '__main__':
    manager.run()

启动程序我们需要先创建迁移仓库:

$ python manage.py db init

创建迁移脚本,migrate子命令用来自动创建:

$ python manage.py db migrate -m "v1.0"

更新数据库操作:

$ python manage.py db upgrade

运行程序:

$ python manage.py runserver --host 0.0.0.1

项目地址

here

如果对大家有帮助,不妨star鼓励一下啊。

大致效果:




这里的上一章和下一章的实现大家可以作为思考题,自己动手实现一下,还是很有意思的,也可以参考我的实现方法,如果你的更好欢迎交流。

由于篇幅有点长,可能说的不详细,大家见谅。本人才疏学浅,如果有错误,麻烦大家指正。还有就是知乎的编辑器太蠢了,我都是写在自己的编辑器里面复制过来的,连复制过来都让我想哭,服了。

如果后续顶点网改动了,我就真的不在更新这个项目了。大家只要改一改爬虫的实现就可以了,我们将爬虫封装起来,其他的都不需要去改。

如果大家觉得不错,欢迎访问我的博客网站,here。目前也没多少人浏览,感紧去抢个位置吧。

----------------------------------------------

更新:

由于我在搜索的过程中发现我想要的书都会在结果的第一页,也就是越符合关键词越靠前。所以为了运行的速度,爬虫就只抓取第一页结果,如果大家不满足这么多可以更改爬虫抓取结果页的方法。加一个page,for循环就可以了。

网站我也经结合Gunicorn部署上线了,大家可以访问:MyNovels

具体的部署代码的改变,大家自己转到项目地址了解吧,由于在部署中一直爆数据库链接错误,然后我发现Search模型并没有实际意义,所有直接删掉Search模型。还有一些改动大家转到项目地址(很详细)。

喜欢记得Star哦。

更新

有些小伙伴觉得只抓取一页数据不满足,那么我改了一下源码,可以抓取10页。但是为了爬虫不拖太久的时间,没获取一页就调用爬虫爬一页。

由于中文吃饭的时候发现网站会出现请求时间过长而出现错误,然后我研究了一下,发现是re匹配速度太慢了,于是我把爬虫部分改了。使用xpath提取数据,这样爬虫运行时间缩短了很多。还有就是网站的服务器不稳定,容易出现500错误,但是刷新一下就好了。我处理了一些错误页面,加了个刷新按钮。

喜欢记得Srat啊。


谢谢阅读

编辑于 2017-08-02

文章被以下专栏收录