yokon's blog

基于flask的静态博客

2018.02.21

我比较喜欢简单的东西,起初我的博客是很简单的,只有最基础的编辑文章和管理文章。 连评论框都没想去弄,现在想想确实有道理。最近一段时间忙于春节拜年,利用一些琐碎 的时间构思,实现了一个基于 flask 的静态博客。我个人是比较喜欢用markdown的,所 有这只适合于喜欢用markdown写东西的小伙伴。不了解markdown语法的可以看这里{:target="_blank"}。

或许你用过基于node.jshexo或者基于pythonPelican这种重量级静态站点生 成器(我也用过),但是为何不自己写一个呢。

最近家里人催着找对象,有点难受,我就将项目改名为quietquiet是一个静态博 客,他的特点在于支持用户上传他写好的markdown文章,然后解析markdown生成 html文件,并且依据规定样式模版,保存为静态html文件。当然程序支持Meta信 息,可以将文件的信息写好,好让程序判断他属于的标签、分类。对于存储一些必要的博 客索引信息,我们可以使用python标准库的shelve库。

博客样式主要模仿Pelican的主题nest{:target="_blank"},为什么 用它。应为最近逛 python之禅{:target="_blank"} 博客的时候,看到这个主题,一 来简洁美观,二来样式模仿起来简单。

quiet运行预览:

首页:

post30_1.jpg

文章页:

post30_2.jpg

构思

我将quiet的实现分为三个部分:

  1. 生成部分,负责转换md文件为html,并且生成对应的标签、分类信息;
  2. 模版部分,是生成html文件的页面结构模版;
  3. 路由部分,负责索引各个文件的资源,比如文章页,标签页等。

生成部分自然是最核心的部分,对于解析markdown,我们使用pythonmarkdown 库,这个库提供了很多特殊语法的扩展,详细看[这里](https://github.com/Python- Markdown/markdown)。文档写的不是很清楚,但是他的一些扩展都在源码/extensions文 件夹下,比如meta,codehilite等。

模版部分采用flask内置的jinja2语法,给生成的页面提供文章页、标签页等页面结构 模版。

路由部分就是用户访问指定url,路由便将指定位置的资源返回给用户。

项目结构:

quiet
	/quiet		-- flask项目目录
		/static		-- 存放静态文件目录包括生成的html
		/templates		-- html 模版目录
		__init__.py
		views.py    	-- 路由
	/source  	-- 存放 md 文件目录
	generate.py  	-- 生成 html 程序
	config.py  	-- 配置文件
	run.py  	-- 启动文件

生成功能

我们就先来实现一个转换md文件生成html文件的生成类。首先先实现转换mdhtml的功能:

安装markdown库和他的代码高亮扩展依赖:

pip install markdown

markdown文件转换为html函数:

# generate.py
import codecs # 标准库

from markdown import Markdown


def markdown_to_html(file):
	with codecs.open(file, mode='r', encoding='utf-8', errors='ignore') as f:
		body = f.read()
		md = Markdown(extensions=['fenced_code', 
			'codehilite(css_class=highlight,linenums=None)',
            'meta', 'admonition', 'tables'])
        content = md.convert(body)
        meta = md.Meta if hasattr(md, 'Meta') else {}

        ...

codecs这是python内置的标准库,我也是在看markdown库的文档才知道这么个库, 主要作用是编码的转换。其实他和普通的文件读写没什么不同,大家可以自己查看标准库 说明。

Meta扩展

extensions参数传入的是markdown库的扩展,其中最主要的是meta。该扩展添加了 用于定义文章数据信息的语法,meta语法包含一系列键值对,形如:

title: 我好帅
summary: 我真的好帅啊
author: 我啊
datetime: 2018-02-21
tag: 随笔
    生活
category: 无分类
balabala: xxxx


正文开始

注意:

  • 关键字不区分大小写,可以由字母,数字,下划线和破折号组成,必须以冒号结尾。这 些值由冒号后的任何内容组成,可以是空白的。

  • 如果一个关键字包含多个值,比如标签,必须写在在下一行且有字少4个空格的缩进。关 键字可以根据需要具有尽可能多的行。

  • 第一个空白行结束文档的所有 meta 数据。因此,正文和 meta 之间字少有一个空行, 并且文档的第一行不能为空。

  • 在 Markdown 进行任何进一步处理之前,所有元数据都将从文档中剥离,不会出现在正 文中。

也就是说,上面的meta信息解析出来其实是一个字典,我们将他保存为text测试一 下:

>>> md = Markdown(extensions = ['meta'])
>>> html = md.convert(text)
>>> print(html)
<p>正文开始</p>

>>> print(md.Meta)
{
'title' : ['我好帅'],
'summary' : ['我真的好帅啊'],
'author' : ['我啊'],
'datetime' : ['2018-02-21'],
'tag' : ['随笔','生活'],
'category' : ['无分类'],
'balabala': ['xxx']
}

fenced_code扩展

该扩展主要定义了代码块的另一种写法,解决了只能缩进写代码块的限制。在标准的 markdown语法中,写代码块是在代码前加一个 tab 或 4 个空格的缩进。而有了 fenced_code扩展,我么可以这么写代码:

```python
print('hello world')
```

解析打印出来就是:

'<pre><code class="python">print(\'hello world\')\n</code></pre>'

codehilite扩展

该扩展主要提供了代码块的高亮及行数功能,接收的两个参数css_class是解析后div 标签的 css 类名称,默认是codehilite,我们把他设置为highlightcodehilite扩展依赖且使用Pygments库提供的代码高亮样式。安装:

pip install pygments

pygments库中是不带样式的,看他的文档,我们可以使用他生成样式,在命令行输入:

pygmentize -f html -S colorful -a .highlight > default.css

这样就生成了一个default.css文件,我们把他放到static/lib/文件夹下。

linenums参数提供显示代码块行号的功能,设为True即为显示行号,默认为None不 显示。

admonition和tables扩展

admonition是一个警告样式的语法,示例如:

!!! 注意
    我真的很帅。

解析为:

<div class="admonition note">
<p class="admonition-title">注意</p>
<p>我真的很帅。</p>
</div>

tables扩展顾名思义就是提供了在markdown中创建表的功能:

| 默认列 | 左对齐列 | 右对齐列 | 居中列 |
|:----|:----|----:|:----:|
| Hello | Hello | Hello | Hello |

除了这些扩展外,还有比如生成文章目录的toc扩展,还有extra扩展,大家自行了 解。

配置Jinja2

from jinja2 import Environment, FileSystemLoader

def markdown_to_html():
	...
		...

		# 解析meta获得信息
		data = parse_meta(file, meta)

		env = Environment(loader=FileSystemLoader("./quiet/templates"))
		template = self.env.get_template('post.html')
		html = template.render(
		    article=content,
		    data=data,
		    title=data.get('title')
		)
		return html

def parse_meta(file, meta):
	pass		

env是创建的一个默认jinja2模板环境,我们把他设为./quiet/templates目录,这 里面存放我们自己写的所有模版,比如post.html

我们只需要调用get_template()方法从这个环境中加载模板,并会返回已加载的 template。用若干变量来渲染它,则需要调用render()方法。我们像模板中传入解析 后的html文档,和解析后的meta信息,和文章标题。

解析Meta

为什么要有这一步呢,主要是有些文件可能本身没有写meta信息,那么我们就要给他生 成默认的信息。

import os
from datetime import datetime


def markdown_to_html(file):
	...

def parse_meta(file, meta):
	now = datetime.now().strftime('%Y-%m-%d')
	date = meta.get('datetime')[0] if meta.get('datetime') else now
	tag = meta.get('tag', '其他')
	category = meta.get('category')[0] if meta.get('category') else '无分类'
    title = meta.get('title')[0] if meta.get('title') else os.path.splitext(os.path.basename(file))[0]
    summary = meta.get('summary')[0] if meta.get('summary') else '无描述'
    url = meta.get('url')[0] if meta.get('url') else str(title)+'.html'

    data = {
        'datetime': date,
        'tag': tag,
        'category': category,
        'title': title,
        'summary': summary,
        'url': url
    }

    return data

如果meta数据没有datetime日期信息,那么就默认是现在的时间,格式为:2018-02- 21。

os.path.splitext(os.path.basename(file))[0]得到的是去掉扩展名的文件名。

保存文件

既然生成了html,接下来就是保存它。

def save_html(file, html):
	filename = os.path.splitext(file)[0] + '.html'
	_generated_folder = './quiet/static/generated/'
	with codecs.open(_generated_folder+'/'+filename, 'w', 'utf-8') as f:
		f.write(html)

def generate():
	html = markdown_to_html(file)
	save_html(file, html)

我们将存储的文件夹设置为./quiet/static/generated/generate()函数是主运行函 数,但是我们需要file参数所属的markdown文件。

扫描所有md文件

我们将存放markdown的文件夹设置为./source/

def load_folder(folder):
	for root, dirs, files in os.walk(folder):
		for name in files:
			if os.path.splitext(name)[1].lower() == '.md':
				md = os.path.join(root, name)
				yield md

def generate():
	_source_folder = './source/'
	for file in load_folder(_source_folder):
		html = markdown_to_html(file)
		save_html(file, html)

渲染标签页

如果想要渲染标签页或者是首页,我们就需要一个存储所有文章的list。当然只需要存 放meta里的标题,标签,分类等信息就好了。有了这个 list,我们就可以获得我们需要 的标签索引和分类索引信息了。

def markdown_to_html(file):
	...
		...

		# 解析meta获得信息
		data = parse_meta(file, meta)
		update_post_data(file, data)

		...

_posts = []

def update_post_data(file, data):
	generate_file = os.path.splitext(os.path.basename(file))[0] + '.html'
    data['filename'] = generate_file
    _posts.append(data)
    render_tag_posts(data['tag'])
    render_cate_posts(data['category'])
    render_index_html()

def render_tag_posts(tag):
	pass

def render_cate_posts(category):
	pass

def render_index_html():
	pass

我们设置_posts为存放数据的 list。update_post_data(file, data)函数接收的 data参数就是解析meta数据得到的data。我们加入一条html文件存放的文件名, 好让我们在路由调用的时候好索引到他们。

后面调用的几个函数是在文章数据 list 发生变化时,就调用他们更新对应页面。

def render_tag_posts(tag):
	"""
	渲染一个标签下所有文章
	:param tag: 这是一个标签列表
	"""
	tag_posts = []
	for p in _posts:
		for t in tag:
			if t in p.get('tag'):
				tag_posts.append(p)
				env = Environment(loader=FileSystemLoader("./quiet/templates"))
				template = env.get_template('tag.html')
	            html = template.render(
	                posts=tag_posts,
	                tag=t,
	                title='标签: ' + t
	            )
	            filename = t + '.html'
	            save_html(filename, html)
            tag_posts = []

这一段很好理解,就是实现的很蠢了。判断标签是不是在文章的标签列表里,如果在,那 么文章就是该标签的文章,将他放入tag_post列表,并且渲染页面。

分类页和首页的渲染同理,不去过多赘述,相信小伙伴自己可以搞定。如有偷懒的小伙伴 可以直接查看项目源码:quiet

存储数据

静态博客不需要进行数据库的交互,但是一直将博客的文章索引信息保存在本地列表中, 不是好的选择。幸运的是,python的内置模块shelve为我们提供了简单的数据存储方 案。这个库接收一个文件名作为存储数据的对象,相当于一个字典类型,帮我们保存需要 保存的数据,然后关闭它:

>>> import shelve
>>> dat = shelve.open('index.dat')
>>> dat['tag'] = ['音乐','电影','书籍']
>>> dat['tag']
['音乐','电影','书籍']
>>> dat.close()

调用数据:

>>> data = shelve.open('index.dat')
>>> data['tag']
['音乐','电影','书籍']

我们用它来存放博客数据:

def dump_data(self):
    
    dat =shelve.open('./quiet/static/blog.dat')
    dat['post_data'] = self._posts
    dat['tag_data'] = self._tags
    dat['category_data'] = self._categories
    dat.close()

标签数据和分类数据大家自己在我们代码的合适位置,去实现一个存放标签和分类的数据 列表。其实很容易,以标签数据为例。只要在每个文件存放到文章列表前,获得该文件 meta信息的标签数据。判断此标签是否在标签列表中,如不在,保存它,如在则该标签 键对应的文章列表添加该文章:

def update_tags(tag, post_title):
	
	_tags = []
    tags = [t['tag'] for t in _tags]
    for i in tag:
        if i not in tags:
            group = {}
            group['tag'] = i
            group['post_title'] = [post_title]
            _tags.append(group)
        elif i in tags:
            index = tags.index(i)
            _tags[index]['post_title'].append(post_title)

    render_tag_html() # 渲染标签页

优化

上面的代码中很多我们定义的变量,如_post_source_folder等,我们需要将他们放 到配置文件config.py中去,导入到程序中来。这样便于管理程序。

对于生成html静态文件功能的程序,我们可以将他分装起来:

# generate.py
...
from config import *

class Generate(object):
	
	def __init__(self):
		self._generated_folder = GENERATED_PATH
        self._post_folder = POST_PATH
        self._page_folder = PAGE_PATH
        self._posts = []
        ...

    def markdown_to_html(self, file):
    	...

    ...

    def generate(self):
    	...

    def __call__(self):
    	self.generate()

模板

我们来写一些博客基本的html结构模板:

继承模版base.html

<!DOCTYPE html>
<html lang="zh-cmn-hans">
<head>
	<meta charset="UTF-8">
	<title>{% if title %}{{ title }}{% else %}Quiet{% endif %} - 安静
</title>

	{% block head %}
	<link rel="shortcut icon" href="../static/images/favicon.ico" 
type="image/x-icon">
	<link rel="icon" href="../static/images/favicon.ico" type="image/x-
icon">
	<link rel="stylesheet" href="../static/css/blog.css">
	{% endblock %}
</head>
<body>
	<div id="header">
		<div class="header-container">
			<div class="header-nav">
				<div class="header-logo">
					<a href="/" class="float-left">
						Quiet 个人博客
					</a>
				</div>
				<div class="nav float-right">
					<a href="/">首页</a>
					<a href="/categories">分类</a>
					<a href="/tags">标签</a>
					<a href="/page/about">关于</a>
				</div>
			</div>

			<div class="header-wrapper">
				<div class="header-content">
					<h1 class="header-title">
						{% block header_title %}
						{% endblock %}
					</h1>
					{% block data %}{% endblock %}
					<div class="underline"></div>
					<p class="header-subtitle">
						{% block header_subtitle %}
						{% endblock %}
					</p>
				</div>
			</div>
		</div>
	</div>

	<div id="main">
		<div class="main-container">
			{% block content %}
        	{% endblock %}
		</div>
	</div>

	<div id="footer">
		<div class="footer-container">
			<p>
				©2018 <a href="#">Quiet</a> 基于 flask 框架的 
quiet 构建
			</p>
			<p>
				<a href="#">备案号</a>
			</p>
		</div>
	</div>
</body>
</html>

首页模板index.html

{% extends "base.html" %}
{% block head %}
<link rel="shortcut icon" href="../../../static/images/favicon.ico" 
type="image/x-icon">
<link rel="icon" href="../../../static/images/favicon.ico" type="image/x-icon">
<link href="../../../static/css/blog.css" rel="stylesheet">
<link href="../../../static/lib/fontello.css" rel="stylesheet">
<link href="../../../static/lib/pygments/default.css" rel="stylesheet">
{% endblock %}

{% block header_title %}
{{ data.title }}
{% endblock %}

{% block data %}
<p class="header-date">
    <span class="post-time">
        <i class="demo-icon icon-calendar"></i> 发表于{{ data.datetime }}
    </span>
    <span class="post-category">
        <i class="demo-icon icon-folder-empty"></i> 分类:
        <a href="/category/{{ data.category }}">{{ data.category }}</a>
    </span>
</p>
{% endblock %}

{% block header_subtitle %}
{% if data.tag %}
{% for tag in data.tag %}
<a href="/tag/{{tag}}"><i class="demo-icon icon-tags"></i> {{ tag }}</a>
{% endfor %}
{% endif %}
{% endblock %}

{% block content %}
{{ article }}
{% endblock %}

其他模板类似。其中favicon.ico是站点图标,blog.css是博客的样式, fontello.css是引入的一个矢量图标 css 库,default.csspygments库生成的代 码高亮样式。

路由

静态博客的路由实现起来就很简单了,只要将对应的静态资源返回就可以了。返回静态资 源,我在之前的文章中提到过,大家可以看看[这里] (https://www.yukunweb.com/2017/12/flask-web-sitemap/)。使用的是flasksend_from_directory方法。

# quiet/main.py
from flask import send_from_directory

from . import app

@app.route('/')
@app.route('/index')
def index():
	"""首页"""
    return send_from_directory('static', 'generated/index.html')

@app.route('/tags')
def tag():
	"""标签页"""
    return send_from_directory('static', 'generated/tags.html')

@app.route('/categories')
def category():
	"""分类页"""
	pass

@app.route('/tag/<tag>')
def tag_post(tag):
	"""tag标签所有文章索引页"""
	return send_from_directory('static', 'generated/{tag}.html'.format(tag=tag))

@app.route('/post/<post>')
def post(year, month, post):
	pass

...

我们将实例Flask应用放入__init__.py中:

# quiet__init__.py
from flask import Flask

from generate import Generate

app = Flask(__name__, template_folder='templates')
app.config.from_object('config')

gen = Generate()

from . import main
# 这里注册蓝本

上传文件

严格地说,上传文件的路由并不会暴露给所有人,这样所有人都可以上传自己的md文 件,那还得了。所以上传文件的路由只会让管理员访问,进入上传页会被程序判断是否是 管理员,如是就进入上传页,如不是,就跳转到管理员登录页。这个功能如果不想麻烦自 己写个装饰器的话,就老老实实的用flask-login扩展。

对于管理员登录路由我这里不去赘述,大家可以自己实现或者直接查看quiet

我们将管理员蓝本注册到__init__.py中去:

# quiet/__init__.py

# 这里注册蓝本
from .admin import admin

app.register_blueprint(admin, url_prefix='/admin')

上传路由:

imprt os
from flask import Blueprint, request, url_for, redirect, render_template

from . import app, gen

admin = Blueprint('admin', __name__)

@admin.route('/upload/post', methods=['GET', 'POST'])
@login_required
def upload_post():
    """
    支持用户上传 md 文件并生成 html
    """
    source_folder = app.config['POST_PATH']
    if request.method == 'POST':
        file = request.files['file']
        filename = file.filename
        path = os.path.join(source_folder, filename)
        file.save(path)
        # 生成 html
        gen()

        return redirect(url_for('index'))
    return render_template('upload_post.html', title="上传文章")

上传html页面大家自己完成,只需要一个上传表单就可以了。

文件的上传操作可以查看文档:here

flaskreauest方法接收管理员上传文章,并且保存到存放md文件的文件夹。然后 调用分装好的Generate类,生成保存并且渲染html页面资源。

博客api索引

如果想要实现restful应用架构,就用到我们保存的blog.dat数据了。使用flask做 api 实在是很简单的,但是我们需要先封装一下载入数据获取索引信息的类:

# utils.py
import shelve

from config import BLOG_DAT # 保存索引信息的文件

class ImportData(object):
    """
    载入 shelve 保存的博客数据
    """

    _data = {}

    @classmethod
    def _load_data(cls):
        """载入数据"""
        data = shelve.open(BLOG_DAT)
        for i in data:
            cls._data[i] = data[i]

        return cls._data

    @classmethod
    def get_data(cls):
        """获取数据"""
        if len(cls._data) == 0:
            cls._load_data()

        return cls._data

    @classmethod
    def reload_data(cls):
        """重新载入数据"""
        cls._load_data()

@classmethod是用来指定一个类的方法为类方法,调用他可以直接使用: ImportData.get_data()来返回一个字典数据。而不需要实例一个对象来调用。

api路由

首先需要注册蓝本:

# quiet/__init__.py
...

from . import main, api, admin

# 这里注册蓝本
from .api import api
from .admin import admin

app.register_blueprint(api, url_prefix='/api')
app.register_blueprint(admin, url_prefix='/admin')

api 路由:

from flask import Blueprint, jsonify

from utils import ImportData

api = Blueprint('api', __name__)

@api.route('/posts')
def get_posts():
    """
    所有文章信息
    :return: json
    """

    return jsonify(ImportData.get_data().get('post_data'))

@api.route('/post/<int:id>')
def get_post(id):
    """
    指定 id 文章信息
    """
    for p in ImportData.get_data().get('post_data'):
        if p.get('id') == id:

            return jsonify(p)
    return jsonify({'msg': '没有数据'})

@api.route('/pages')
def get_pages():
    """
    所有页面
    """

    return jsonify(ImportData.get_data().get('page_data'))

...
...

flaskjsonify方法可以很简单的将字典数据格式化为json数据,返回给客户端。

大功告成

到这里,静态博客的大部分功能都已经完成了,剩下来的就是运行程序了:

# run.py
from quiet import app
from generate import Generate


if __name__ == '__main__':
    gen = Generate()
    gen()
    app.run()

先生成静态资源,接着启动flask程序。这时候访问:127.0.0.1:5000就可以看到博客 效果了。由于上面我并没有写css样式,所以样式可以基于大家的喜好,文章的扩展大家 也可以基于喜好自行扩展。

如果大家对整体逻辑或者是代码不明白的可以查看项目:quiet

如果喜欢不妨star,如果有建议不妨fork给我提PR

祝大家早日找到对象QAQ~。