Qexo 本地私有化部署教程及优化

前言:

  • 之前部署了自己的hexo博客,一直用的是 hexo-admin插件或者本地 typora写文章。苦苦寻找,终于看到了一个不错的后台管理页面 qexo。本文将要介绍的是,如何将 qexo本地化部署及避坑,以及修改 qexo源码进行美化,不会详细介绍 hexo的部署流程(参见其他文章)。
  • 每个人的博客部署方式都不一样,Github,Vercel,Cloudflare等等,都能满足自动化部署的需求,这些方式我都尝试过,本次将采用私有化部署方式,大致需求如下。
    • 需要一台主机(云主机/本机),本文以云主机进行演示
    • 准备一个github、gitee、gitcode、云效等Git账号(非必须)

接下来,看看我是如何用华为云+宝塔面板+qexo+hexo+云效,搭建一个自动化博客管理流水线。

(华为云、宝塔、云效的教程不再赘述)

一、拉取Qexo的代码,把它运行起来

1、 拉取qexo代码,存储到任意目录。例如:/home/www/Qexo-master/

http://qexo.icctv.top/file/2024-10/a6bf9e34bcd94580a5c7b2c5a5790a8b.png

2、 根据官网教程,在根目录新建 configs.py,写入官网教程给的内容,看代码注释,看代码注释🙂

*本来想用sqlite存储,但是项目没运行起来,mysql吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import pymysql
pymysql.install_as_MySQLdb()
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': 'qexo',
'USER': 'root',
'PASSWORD': 'password',
'HOST': '127.0.0.1',
'PORT': '3306',
'OPTIONS': {
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'"
}
}
}
# DOMAINS一定要写你服务器的ip或域名,否则无法访问
# 例如:DOMAINS = ["124.71.14.149", "qexo.icctv.top", "127.0.0.1"]
DOMAINS = ["127.0.0.1", "yoursite.com"]

3、执行python运行命令,如果使用宝塔面板则跳过这一步

*此处pip或pip3 *

1
2
3
4
pip3 install -r requirements.txt
python3 manage.py makemigrations
python3 manage.py migrate
python3 manage.py runserver 0.0.0.0:8000 --noreload

此时,已经可以打开qexo后台页面,如果打不开就多看看文档。

4、 如果你跟我一样,用的是宝塔面板,则进行这一步

4.1 在宝塔面板的网站中新增一个python项目,填写下面内容

http://qexo.icctv.top/file/2024-10/0ba48edc452f4bb6b92a1ea4f996145f.png

4.2 然后在终端中执行第3步的命令,因为宝塔和宿主机的python环境是隔离的。

http://qexo.icctv.top/file/2024-10/4ddfa6dfa67b4bf8848e1b6c2ee8e1da.png

执行完毕,点击运行,你的qexo项目就能启动了。(云主机记得修改安全组规则)

4.3 在配置中填写你的域名,更好的访问 qexo后台

http://qexo.icctv.top/file/2024-10/15d90b1f077a419d8d685f12d97ab7db.png

还可以配置自动申请SSL证书等,自己动手尝试吧。

二、Qexo的优化操作

  • 优化图床,使 Qexo支持本地图片上传,将图片保存到云主机,满足安全性。
  • 优化文章列表,使文章按创建时间排序,并显示创建时间。
  • 优化图片列表,图片直接显示缩略图,不必手动点击。
  • 优化写文章自动加载标签和分类(巨好用)。

开搞!

1、本地图床

效果图

http://qexo.icctv.top/file/2024-10/e8b97bc30b344274bf5dbb3e2a65291a.png

1.1 在Qexo源码目录 hexoweb/libs/image/providers下新增 local.py文件,可以看到该目录下还有 ftp,github等图床配置。

local.py文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
"""
@Project : custom
@Author : GG
@Blog : https://www.oplog.cn
"""
import datetime
import os
import uuid

from ..core import Provider


def delete(config):
path = config.get("delete_url")
if os.path.exists(path):
os.remove(path)
return "OK"


class Main(Provider):
name = '本地服务'

params = {
'api': {'description': '本地路径', 'placeholder': '图床图片上传的 目录'},
'custom_url': {'description': '自定义域名', 'placeholder': '返回带自定义域名的URL,如果没有则返回全路径'},
}

def __init__(self, api, custom_url):
self.path = api
self.custom_url = custom_url
self.delete_url = None

def upload(self, file):
old_name = os.path.splitext(file.name)
content = file.read()
date = str(datetime.date.today().strftime("%Y-%m"))
file_name = date + "/" + str(uuid.uuid4()).replace("-", "") + old_name[1]
save_path = self.path + "/" + file_name
parent_dir = os.path.dirname(save_path)
os.makedirs(parent_dir, exist_ok=True)
with open(save_path, 'wb') as target_file:
target_file.write(content)
return [self.custom_url + '/' + file_name, {"provider": Main.name, "delete_url": save_path}]

1.2 同时,在 hexoweb/libs/image/providers目录下的 __init__.py文件中,新增一行代码。

1
2
3
4
5
6
7
8
9
10
11
_all_providers = {
# 新增local
local.Main.name: local,
custom.Main.name: custom,
s3.Main.name: s3,
ftp.Main.name: ftp,
dogecloudoss.Main.name: dogecloudoss,
alioss.Main.name: alioss,
gitHub.Main.name: gitHub,
upyun_storage.Main.name: upyun_storage,
}

完成后,打开即可在配置中看到本地服务图床类型,添加本地路径和自定义域名。

1.3 修改python项目的配置文件

添加了自定义域名后,还需要配置nginx反向代理,才能访问图片。如果是单独装的nginx,直接修改 nginx.conf文件即可。

增加一个location配置项

1
2
3
location /file {
alias /home/www/file/;
}

宝塔配置文件

http://qexo.icctv.top/file/2024-10/f5104506eb35409386e88ab57e5ae26b.png

完成后,即可使用本地图床,访问图片。

2、文章列表时间倒序

效果图

http://qexo.icctv.top/file/2024-10/f1ae855ac8ce4bb8b020b2bde053eda8.png

1、首先添加创建时间字段 ctime

hexoweb/libs/platforms/providers/local.py文件,找到 get_path函数,修改43行,增加代码。注意要在上一行的末尾加个逗号(,)。

1
"ctime": os.stat(filedir).st_ctime

http://qexo.icctv.top/file/2024-10/971dfb72739a493093f0bd7e9ce1b2f9.png

2、然后将 ctime保存到数据库

hexoweb/libs/platforms/core.py文件中,增加一行代码,注意要在上一行的末尾加个逗号(,)。

1
"ctime": posts[i]["ctime"]

http://qexo.icctv.top/file/2024-10/efc5c0a7ee324400ba6af3adfadb7d98.png

3、数据回显时根据 ctime进行排序

hexoweb/views.py文件中,搜索 elif "posts" in load_template:这个判断语句,增加下面代码

1
posts.sort(key=lambda x: x.get("ctime", float(1)), reverse=True)

http://qexo.icctv.top/file/2024-10/c3cfbfa3a6734a44893244fb4f6c6105.png

4、修改文章列表模板

找到 templates/home/posts.html文件,这是文章列表的模板文件。

先在上面的html部分,加入 创建时间标题,一个 th标签。

1
2
3
<th class="text-secondary text-xxs font-weight-bolder opacity-7">
<span class="text-secondary">创建时间</span>
</th>

http://qexo.icctv.top/file/2024-10/b96549d56c9945099dbd2c4cb0f60375.png

然后在下面的JavaScript函数部分,返回的html文本中,加入 td标签。

1
2
3
4
<!-- # fix add date column -->
<td class="text-xs font-weight-bold">
@@ctime@@
</td>

http://qexo.icctv.top/file/2024-10/af9b9bf3c46842669b86c67dcf5d340a.png

最后,在函数中的数据处理部分,添加对 ctime字段的数据转换操作。

小心,不要把代码改出BUG

1
2
3
4
5
6
7
8
9
<!-- # fix add date column -->
let ctime = page_posts[i].ctime * 1000;
let formattedDate = new Date(ctime).toLocaleString();
list += post_temp.replaceAll("@@name@@", excerpt_by_local(page_posts[i].name, 50))
.replaceAll("@@size@@", page_posts[i].size)
.replaceAll("@@ctime@@", formattedDate)
.replaceAll("@@status@@", status)
.replaceAll("@@path@@", encodeURIComponent(page_posts[i].path))
.replaceAll("@@fullname@@", encodeURIComponent(page_posts[i].name));

http://qexo.icctv.top/file/2024-10/fca9f7188e48423da94cd7ecd017e1b7.png

3、图片列表缩略图

效果图

http://qexo.icctv.top/file/2024-10/ebc5bdd6b36145649d1c8f2d1a8d3345.png

1、修改图片列表模板文件

templates/home/images.html文件的 181行写入一个 img标签,用来显示缩略图

1
2
<!-- # fix 图片缩略图 -->
<img src="@@url@@" class="avatar avatar-sm me-3" alt="xd"></img>

http://qexo.icctv.top/file/2024-10/8fee605db78a4a20a375d99187d19a17.png

4. 自动加载标签和分类

效果图

http://qexo.icctv.top/file/2024-12/0a2807f0710c4d4da235ec7389dcc608.gif

1. 要改这几个文件

http://qexo.icctv.top/file/2024-12/e7c0b14603b44ca2bd32a3aa469ca244.png

2. 修改api.py

导入yaml包

http://qexo.icctv.top/file/2024-12/fe14d77911d24e3dba7e1a82243bfc8b.png

1
import yaml

增加 refresh_tags_categories方法

http://qexo.icctv.top/file/2024-12/ba238ad3baeb4ef59868bf89c5af5ec6.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@login_required(login_url="/login/")
def refresh_tags_categories(request):
try:
provider = json.loads(get_setting("PROVIDER"))
path = provider['params']['path'] + "/source/_posts"
tag_list = set()
categories_list = set()

for root, dirs, files in os.walk(path):
for file in files:
file_path = os.path.join(root, file)
try:
categories, tag = extract_categories_and_tags(file_path)
tag_list.update(tag)
categories_list.update(categories)
except Exception as e:
logging.error(f'----------extract_categories_and_tags failed on file:{file_path}')

context = {"msg": gettext("SAVE_SUCCESS"), "status": True,
"data": {"tags": sorted(list(tag_list)), "categories": sorted(list(categories_list))}}
except Exception as e:
logging.error(e)
context = {"msg": gettext("CAPTCHA_GET_FAILED"), "status": False}
return JsonResponse(safe=False, data=context)


# 提取分类和标签
def extract_categories_and_tags(file_path):
# 正则表达式匹配categories和tags
pattern = r'^---\s*([\s\S]*?)\s*---'
with open(file_path, 'r', encoding="utf-8") as f:
content = f.read(500)
pattern_match = re.search(pattern, content, re.MULTILINE)
if pattern_match:
json_data = yaml.safe_load(pattern_match.group(1))
# 提取分类和标签
categories = flat_map(json_data.get('categories', []))
tags = flat_map(json_data.get('tags', []))
return categories, tags


# 将嵌套列表展开
def flat_map(arr):
flat_list = []
for a in arr:
if isinstance(a, list):
flat_list.extend(a)
else:
flat_list.append(a)
return flat_list

3.修改urls.py,声明接口

http://qexo.icctv.top/file/2024-12/4ee27c9012484267b77690f56480fbf8.png

1
path('api/refresh_tags_categories/', refresh_tags_categories, name='refresh_tags_categories'),

4.修改new.html文件

增加刷新函数 refreshTagAndCategory,发送ajax请求

http://qexo.icctv.top/file/2024-12/6676bead38414ee8b12965e0619b8b90.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function refreshTagAndCategory() {
if (!_status) {
return false;
}
$.ajax({
url: '/api/refresh_tags_categories/',
method: 'get',
dataType: 'JSON',
success: function (res) {
console.log(res);
if (res.status) {
notyf.success('{{ "UPDATE_SUCCESS" | gettext }}');
let tags_list = res.data.tags;
let categories_list = res.data.categories;
let categories_str = "";
for (let i = 0; i < categories_list.length; i++) {
categories_str += '<option value="' + categories_list[i] + '">' + categories_list[i] + '</option>'
}
$("#category_select_list").html(categories_str);

let tags_str = "";
for (let i = 0; i < tags_list.length; i++) {
tags_str += '<option value="' + tags_list[i] + '">' + tags_list[i] + '</option>'
}
$("#tag_select_list").html(tags_str);
} else {
notyf.error('{{ "SAVE_FAILED" | gettext }}');
}
},
error: function (res) {
loading.destroy();
notyf.error("{{ "NETWORK_ERROR" | gettext }}");
}
})
}

修改 showSidebar函数,内容较复杂,下面直接贴我的整个函数

http://qexo.icctv.top/file/2024-12/8f92cc76f4d84aacb6c58bfebc8845e3.png

—————-这里是改动说明—————–http://qexo.icctv.top/file/2024-12/a8fa70a465984c40bcb573d54b24e889.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
function showSidebar(first = false) {
let html = "", content, front = front_matter, ifTag = false, ifCategory = false;
if (!first) {
front = get_front_matter();
save_bottom();
OrgSidebar = [...Sidebar];
}
for (let i = 0; i < OrgSidebar.length; i++) {
if (OrgSidebar[i]["search"] === "tags") {
html += `<div class="form-group">
<label class="form-control-label"><i class="${OrgSidebar[i]["icon"]}"></i>${OrgSidebar[i]["name"]}</label>
<div class="input-group mb-0">
<input type="text" class="form-control" placeholder="{{ "ADD_TAG" | gettext }}" name="qexo-tag" id="qexo-tag" list="tag_select_list">
<datalist id="tag_select_list"></datalist>
<button class="btn mb-0 childButton" type="button" onclick="add_tag($('#qexo-tag')
.val());$('#qexo-tag').val('')">+</button>
<button class="btn mb-0 childButton" type="button" onclick="refreshTagAndCategory()">
<svg class="bi bi-arrow-clockwise" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M3.17 6.706a5 5 0 0 1 7.103-3.16.5.5 0 1 0 .454-.892A6 6 0 1 0 13.455 5.5a.5.5 0 0 0-.91.417 5 5 0 1 1-9.375.789z"/>
<path fill-rule="evenodd" d="M8.147.146a.5.5 0 0 1 .707 0l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 1 1-.707-.708L10.293 3 8.147.854a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
<div id="tag-container"></div>
</div>`;
ifTag = true;
} else if (OrgSidebar[i]["search"] === "categories") {
html += `<div class="form-group">
<label class="form-control-label"><i class="${OrgSidebar[i]["icon"]}"></i>${OrgSidebar[i]["name"]}</label>
<div class="input-group mb-0">
<input type="text" class="form-control" placeholder="{{ "ADD_CATEGORY" | gettext }}" name="qexo-category" id="qexo-category" list="category_select_list">
<datalist id="category_select_list"></datalist>
<button class="btn mb-0 childButton" type="button" onclick="add_category($
('#qexo-category').val());$('#qexo-category').val('')">+</button>
<button class="btn mb-0 childButton" type="button" onclick="refreshTagAndCategory()">
<svg class="bi bi-arrow-clockwise" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M3.17 6.706a5 5 0 0 1 7.103-3.16.5.5 0 1 0 .454-.892A6 6 0 1 0 13.455 5.5a.5.5 0 0 0-.91.417 5 5 0 1 1-9.375.789z"/>
<path fill-rule="evenodd" d="M8.147.146a.5.5 0 0 1 .707 0l2.5 2.5a.5.5 0 0 1 0 .708l-2.5 2.5a.5.5 0 1 1-.707-.708L10.293 3 8.147.854a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
</div>
<div id="category-container"></div>
</div>`;
ifCategory = true;
} else {
if (OrgSidebar[i]["search"] === "updated" || OrgSidebar[i]["search"] === "lastmod") {
content = getRFC3339();
} else if (front[OrgSidebar[i]["search"]] !== undefined) {
content = front[OrgSidebar[i]["search"]];
} else content = "";
content = (typeof content) === "object" ? JSON.stringify(content).replaceAll("\"", """) : content.toString().replaceAll("\"", """)
html += `<div class="form-group">
<label class="form-control-label"><i class="` + OrgSidebar[i]["icon"]
+ `"></i> ` + OrgSidebar[i]["name"] + `</label>
<input type="text" name="title"
class="form-control"
placeholder="` + OrgSidebar[i]["name"] + `"
id="` + OrgSidebar[i]["search"] + `"
value="` + content + `"></div>`;
}
delete front[OrgSidebar[i]["search"]];
}
$("#sidebar-container").html(html);
if (ifTag) show_tags();
if (ifCategory) show_categories();
front_matter = front;
show_bottom();
set_vditor_size();
// 监听分类、标签回车键
$('#qexo-tag').bind('keypress', function (event) {
if (event.keyCode === 13) {
event.preventDefault();
add_tag($('#qexo-tag').val());
$('#qexo-tag').val('')
}
});
$('#qexo-category').bind('keypress', function (event) {
if (event.keyCode === 13) {
event.preventDefault();
add_category($('#qexo-category').val());
$('#qexo-category').val('')
}
});
}

另外两个文件改动差不多,其中 edit.html基本一致,而 edittalk.html文件是只需要增加标签的改动,因为 说说没有分类选项。

5.修改edit.html

也是先添加函数 refreshTagAndCategory

http://qexo.icctv.top/file/2024-12/f6b93e51a763414798678e3a9cf1100b.png

改动html,代码不贴了

http://qexo.icctv.top/file/2024-12/9c8b8b2fce2c40a8910f31ac468d2d10.png

6.修改 edit_talk.html

修改html部分,这里只有标签的输入框

http://qexo.icctv.top/file/2024-12/14812e361d4145e8972f8f182e540f72.png

修改函数部分,粘贴 refreshTagAndCategory函数

http://qexo.icctv.top/file/2024-12/29cfc65f10a5405b9614f2b5fdb6fffc.png

三、通过阿里云效,完成流水线部署

因为华为云28块只有一年有效期,为了到时候方便迁移博客,所以我不是直接通过本地运行命令将hexo打包部署的,而是加入了云效流水线,只要提交代码后,就能自动完成部署,类似 githubAction。不需要科学上网,方便操作。

1、注册阿里云账号,进入云效,创建一个Git仓库存放hexo源码(略过)

2、进入流水线,根据指引完成云主机的绑定操作(略过)

3、创建云效流水线

构建阶段选择node.js构建,选择对应node版本,输入构建命令,此处基本与hexo安装时一样。

http://qexo.icctv.top/file/2024-10/c33d659839a847738e152570476877d9.png

部署阶段,选择你的主机,然后将打包好的html文件通过命令拷贝至nginx指向的目录。

http://qexo.icctv.top/file/2024-10/77350a0c7c8e4a1fbbf7208ec681f384.png

4、完成Qexo的自动部署配置

Qexo本地部署配置

http://qexo.icctv.top/file/2024-10/b39197a97e9248e791571dd8d4ef975b.png

自动部署命令建议通过 shell脚本执行,在博客路径 (你的hexo源码目录)下新建 gitpush.sh文件

1
2
3
4
5
# 新建文件的linux命令
touch gitpush.sh

# 赋予权限的linux命令
chmod 777 ./gitpush.sh

gitpush.sh文件中写入下面命令

1
2
3
4
5
6
7
8
9
10
11
12
#! /bin/bash
# 执行$HOME环境变量,否则Git可能无法运行
export HOME=/root

echo "================="
git config --global user.name
git config --global user.email
echo "==================="

git add .
git commit -m "qexo push"
git push

完成以上配置,在使用Qexo编辑完文章后,自动部署阶段会通过Git将hexo源文件上传到云效,由云效进行自动化打包部署。

http://qexo.icctv.top/file/2024-10/306222b35f7649748a98345e475ce59d.png

http://qexo.icctv.top/file/2024-10/8c1c8186da374dc79b8e66ddf79146b4.png