用树莓派托管个人博客网站(三):深浅色切换、访客记录和内容同步自动化

本文系统介绍了在基于 Flask 和 Jinja2 的静态博客系统中,如何实现三大核心增强功能:一是支持用户全局切换并持久化深浅色主题,兼顾首页与文章页的一致性与加载体验;二是通过 nginx 配置记录真实访客 IP 与地理位置,构建轻量级访问日志系统;三是利用 rsync 与 WSL 编写跨平台同步脚本,实现本地写作与树莓派服务器内容的高效自动化更新。这些改进显著提升了网站的可用性、可观测性与维护效率。


🚀项目背景和目标

「用树莓派托管个人博客网站(一):搭建和部署Web应用」中已经用 Flask 框架搭建好了一个个人博客网站。网站的首页展示文章列表,点击文章后进入独立的 HTML 页面。这些文章都由 Typora 生成,为纯静态 HTML 文件。接下来可以对网站功能进一步优化,本文档聚焦于实现以下三个功能:

  1. 用户可以在网站首页点击按钮切换深色/浅色模式,并且此状态保存到 localStorage 变量中实现全局同步。也就是说,首页和文章页保持一致的显示模式。此外,还要保证深色模式下打开文章页没有白屏闪烁,保证用户体验。
  2. 构建一个安全高效的线上请求监测系统,将网站访客的流量特征记录到日志中,并用 cron 工具实现自动化管理。这一功能既可以用于网站的日常调试,也可以用来观察网站的访客特征。
  3. 由于此网站的文章均由 Typora 生成,将 markdown 格式文本导出为 HTML 格式后部署到网站后端的过程较为冗长,所以最好用一个脚本,实现网站内容的自动化。由于树莓派是 Linux 系统,所以为了兼容性最好,这一功能使用 Windows 笔记本上的 WSL 来实现。

🌗深浅色切换

首页添加深浅色切换按钮

在 HTML 模板中添加深浅色切换按钮之前,需要先回顾以下 Flask 应用的响应逻辑,从「用树莓派托管个人博客网站(一):搭建和部署Web应用」中将这部分代码摘抄如下:

python
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
#app.py
                from flask import Flask, Response
                from pathlib import Path
                
                # Flask 是 flask 包里代表整个 Web 应用的一个类
                # 首先定义一个类代表整个 Web 应用,只需要传入一个参数作为这个类的名字,这里就采用本文件的名字
                app = Flask(__name__)
                
                # 如果用户点击网站主页,响应 index() 函数,功能为展示网站的主页
                @app.route('/')
                def index():
                    posts_dir = Path("./posts")
                    posts_dir.mkdir(exist_ok=True)
                    # 提取 "./posts" 路径下的所有文章的名字,得到一个文章列表
                    files = [f.stem for f in posts_dir.glob("*.html")]
                    # 网站的主页是由 Jinja2 模板构建的 index.html 文件,采用 render_template 函数插入文章列表
                    return render_template('index.html', files=files)
                
                # 如果用户点击文章列表中一篇名为 name 的文章,响应 show_post(name) 函数
                @app.route('/post/<name>')
                def show_post(name):
                    posts_dir = Path("./posts")
                    file_path = posts_dir / f"{name}.html"
                    
                    # 如果文章不存在,返回报错信息
                    if not file_path.exists():
                        return "文章不存在", 404
                
                    # 读取文章内容,读取的编码方式设置为 utf-8,读取的内容写入变量 content
                    with open(file_path, 'r', encoding='utf-8') as f:
                        content = f.read()
                
                    # 使用 Response 函数返回文章内容,参数 mimetype 是用来告诉浏览器返回文件的类型
                    return Response(content, mimetype='text/html')
                
                if __name__ == '__main__':
                    app.run(host='127.0.0.1', port=5000, debug=True)

简单地说,这个博客网站的功能是:打开网站主页后,用户可以看到博客网站的标题,下边是一个文章列表;用户点击列表中的任意一篇文章后,可以跳转至文章内容页面。为了增加深浅色切换功能,我们首先要在网站首页的 HTML 模板文件 index.html 中加入按钮的样式:

html
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
<!DOCTYPE html>
                <html lang="zh-CN">
                <head>
                    <!-- ... 其他 head 内容 ... -->
                    <style>
                        /* 深色模式切换按钮样式 —— 无边框、纯图标 */
                        #dark-mode-toggle {
                            position: absolute;
                            /* 下面两行按钮相对于最近的父元素(.header)的定位 */
                            /* 这两个设定值可以营造轻盈的「挂角」设计感 */
                            top: -8px;
                            right: -8px;
                            background: transparent;
                            border: none;
                            padding: 4px;
                            font-size: 1.4em;
                            color: #7f8c8d;
                            cursor: pointer;
                            transition: color 0.2s ease;
                            z-index: 10;
                            border-radius: 50%;
                            outline: none; /* 移除聚焦轮廓 */
                            -webkit-tap-highlight-color: transparent; /* 移除 iOS 点击高亮 */
                            -webkit-appearance: none; /* 移除 Safari 默认样式 */
                            appearance: none;
                        }
                
                        #dark-mode-toggle:hover,
                        #dark-mode-toggle:focus,
                        #dark-mode-toggle:active {
                            outline: none;
                            background: transparent !important;
                            box-shadow: none;
                            color: #2c3e50;
                        }
                    </style>
                    <!-- ... 其他 head 内容 ... -->
                </head>
                </html>

接着,在 index.html 文件的 <body> 部分添加按钮,如下所示:

html
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
                14
<!DOCTYPE html>
                <html lang="zh-CN">
                <body>
                    <!-- ... 这里放置页面内容 ... -->
                    <div class="container">
                        <div class="header">
                            <!-- ... 这里放置页面内容 ... -->
                            <!-- 添加一个按钮 -->
                            <button id="dark-mode-toggle" aria-label="切换深色模式">☀️</button>
                        </div>
                		<!-- ... 这里放置页面内容 ... -->
                    </div>
                </body>
                </html>

文章页面同步深浅色状态

为了让网站首页和文章页面的深浅色模式状态保持同步,需要添加一段脚本。在脚本中定义一个全局变量记录用户的深浅色模式状态,浏览器会根据此全局变量的状态渲染深色模式。具体的添加内容如下:

html
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
<!DOCTYPE html>
                <html lang="zh-CN">
                <body>
                    <!-- ... 这里放置页面内容 ... -->
                    <script>
                        // 当页面 DOM 完全加载完成后执行
                        document.addEventListener('DOMContentLoaded', () => {
                            // 第一步:查找页面上的按钮元素
                            const toggleBtn = document.getElementById('dark-mode-toggle');
                            
                            // 第二步:检查按钮是否存在,以及 DarkReader 是否加载成功(冗余检查)
                            if (!toggleBtn || typeof DarkReader === 'undefined') {
                                console.warn('DarkReader not loaded or button missing');
                                return; // 如果条件不满足,直接退出函数
                            }
                            
                            // 第三步:从 localStorage 中读取用户上次的深色模式偏好
                            const isDark = localStorage.getItem('darkMode') === 'true';
                            
                            // 第四步:根据用户偏好初始化页面状态
                            if (isDark) {
                                // 如果上次是深色模式,启用它
                                DarkReader.enable({ 
                                    brightness: 100,  // 亮度值
                                    contrast: 90,     // 对比度值  
                                    sepia: 10         // 棕褐色调值
                                });
                                // 将按钮文字改为月亮图标(表示当前是深色模式)
                                toggleBtn.textContent = '🌙';
                            } else {
                                // 如果上次不是深色模式,按钮显示太阳图标
                                toggleBtn.textContent = '☀️';
                            }
                
                            // 第五步:为按钮添加点击事件监听器
                            toggleBtn.addEventListener('click', () => {
                                // 检查当前深色模式是否已启用
                                if (DarkReader.isEnabled()) {
                                    // 如果已启用 → 关闭它
                                    DarkReader.disable();
                                    // 记录状态:深色模式关闭
                                    localStorage.setItem('darkMode', 'false');
                                    // 按钮图标改为太阳(表示现在是亮色模式)
                                    toggleBtn.textContent = '☀️';
                                } else {
                                    // 如果未启用 → 开启它
                                    DarkReader.enable({ 
                                        brightness: 100, 
                                        contrast: 90, 
                                        sepia: 10 
                                    });
                                    // 记录状态:深色模式开启
                                    localStorage.setItem('darkMode', 'true');
                                    // 按钮图标改为月亮(表示现在是深色模式)
                                    toggleBtn.textContent = '🌙';
                                }
                            });
                        });
                    </script>
                </body>
                </html>

「渲染」这个工作不需要自己编写脚本去做,互联网上有大量完善的工具,只需要调用即可。这里推荐一个非常好用的工具,叫做 darkreader。找到 .js 文件并保存到 /static/ 路径下即可。我们希望文章页面的深浅色模式和网站主页保持一致,所以在 index.html 文件的 <body> 部分定义了一个全局变量来记录深浅色模式状态,然后需要 darkreader.js 脚本根据这一状态对页面进行渲染。但是,由于文章页面是由 Typora 导出的静态文件,必须要向此文件注入一段脚本才能实现深浅色模式渲染这一功能。于是,可以在 /templates/ 路径中新建一个 darkreader_inject.html 文件,将此文件中的脚本注入到 index.html 页面或者文章页面中,这样用户的浏览器就能根据控制深浅色模式的全局变量对网站主页和文章页面统一地进行渲染了。darkreader_inject.html 文件的内容如下:

html
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
<!-- darkreader_inject.html -->
                <!-- 引入 DarkReader 库,用于实现深色模式功能 -->
                <script src="{{ url_for('static', filename='darkreader.js') }}"></script>
                
                <script>
                    // 当页面 DOM 完全加载完成后执行以下代码
                    document.addEventListener('DOMContentLoaded', () => {
                        
                        // 从浏览器的本地存储中获取深色模式的设置状态
                        // 如果之前用户设置了深色模式,则 localStorage 中的 'darkMode' 值为 'true'
                        // 如果之前用户关闭了深色模式,则 localStorage 中的 'darkMode' 值为 'false'
                        // 如果从未设置过,则返回 null,转换为布尔值后为 false
                        const isDark = localStorage.getItem('darkMode') === 'true';
                
                        // 检查两个条件:
                        // 1. 用户是否设置了深色模式(isDark 为 true)
                        // 2. DarkReader 是否成功加载(typeof DarkReader !== 'undefined')
                        if (isDark && typeof DarkReader !== 'undefined') {
                            
                            // 使用 DarkReader.enable() 方法开启深色模式
                            DarkReader.enable({
                                brightness: 100,  // 亮度设置
                                contrast: 90,     // 对比度设置  
                                sepia: 10         // 色调设置
                            });
                        }
                    });
                </script>

上面脚本中的 DarkReader 变量是在加载的 darkreader.js 内部定义的一个全局变量。当检测到 isDark 变量是真且 DarkReader 变量加载成功时,脚本就会把页面按照设定的参数渲染成深色模式。

修改 Flask 路由

写好了 darkreader_inject.html 文件后,需要修改 app.py 中的文章页路由,动态注入此脚本(下方代码块第15至20行):

python
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
                14
                15
                16
                17
                18
                19
                20
                21
                22
                23
# 如果用户点击文章列表中一篇名为 name 的文章,响应 show_post(name) 函数
                @app.route('/post/<name>')
                def show_post(name):
                    posts_dir = Path("./posts")
                    file_path = posts_dir / f"{name}.html"
                    
                    # 如果文章不存在,返回报错信息
                    if not file_path.exists():
                        return "文章不存在", 404
                
                    # 读取文章内容,读取的编码方式设置为 utf-8,读取的内容写入变量 content
                    with open(file_path, 'r', encoding='utf-8') as f:
                        content = f.read()
                
                    # 注入 darkreader_inject.html 中的脚本
                    darkreader_snippet = render_template('darkreader_inject.html')
                    if '</head>' in content:
                        content = content.replace('</head>', darkreader_snippet + '</head>')
                    else:
                        content = darkreader_snippet + content
                
                    # 使用 Response 函数返回文章内容,参数 mimetype 是用来告诉浏览器返回文件的类型
                    return Response(content, mimetype='text/html')

现在,重启一遍网站服务即可。用另一台设备访问网站,可以看到深浅色模式切换的功能已经实现了。

深色模式防止闪屏

前面虽然已经实现了深浅色模式的切换,但是切换到深色模式后随机进入一篇文章的页面时,总是会出现闪屏现象,用户体验非常糟糕。为了解决这一问题,可以先将页面内容完全隐藏以防止加载时的白色背景闪烁,然后根据用户的深色模式偏好设置来启用 DarkReader 进行深色模式转换,同时为页面预设深色背景避免白色闪烁,等待 200 毫秒让 DarkReader 完成所有样式处理后,再通过平滑的淡入动画显示处理好的深色页面,从而避免了从亮色到暗色的突兀切换现象。按照这一思路,在 darkreader_inject.html 文件中加入一段代码后如下所示:

html
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
<!-- darkreader_inject.html -->
                <style>
                /* 默认隐藏内容,防止闪屏 */
                /* 当页面刚开始加载时,先隐藏所有内容,避免用户看到从亮色到暗色的切换过程 */
                body {
                    visibility: hidden !important;  /* 完全隐藏元素,但保留其占用的空间 */
                    opacity: 0 !important;          /* 设置透明度为0,使内容完全不可见 */
                    /* 定义淡入淡出的过渡动画效果 */
                    transition: opacity 0.3s ease, visibility 0.3s ease;
                }
                
                /* 深色模式背景 */
                /* 当启用深色模式时,为页面设置一个深色的背景色,避免在 DarkReader 完全处理前出现白色背景 */
                body.dark-mode-enabled {
                    background-color: #181a1b !important;  /* 深灰色背景色 */
                    color: #e0e0e0 !important;             /* 浅灰色文字颜色 */
                }
                
                /* 显示内容 */
                /* 当所有准备工作完成后,添加这个类来显示内容 */
                body.ready-to-show {
                    visibility: visible !important;  /* 使元素可见 */
                    opacity: 1 !important;           /* 设置透明度为1,使内容完全显示 */
                }
                </style>
                
                <!-- 加载 DarkReader 库 -->
                <script src="{{ url_for('static', filename='darkreader.js') }}"></script>
                
                <script>
                    // 当页面的 DOM 结构完全加载完成后执行
                    document.addEventListener('DOMContentLoaded', () => {
                        // 从浏览器本地存储中读取用户的深色模式偏好设置
                        const isDark = localStorage.getItem('darkMode') === 'true';
                
                        // 如果用户设置了深色模式,且 DarkReader 库已成功加载
                        if (isDark && typeof DarkReader !== 'undefined') {
                            // 第一步:先添加深色背景类
                            // 这样在 DarkReader 开始处理之前,页面会有一个深色的背景
                            // 避免在 DarkReader 处理过程中出现白色背景的闪烁
                            document.body.classList.add('dark-mode-enabled');
                            
                            // 第二步:启用 DarkReader 深色模式
                            // 这会开始处理页面的所有元素,将它们转换为深色主题
                            DarkReader.enable({
                                brightness: 100,  // 亮度设置
                                contrast: 90,     // 对比度设置  
                                sepia: 10         // 色调设置
                            });
                
                            // 第三步:延迟显示内容,等待 DarkReader 处理完成
                            // 使用 setTimeout 设置一个 200 毫秒的延迟
                            // 这给 DarkReader 足够的时间来处理所有页面元素的样式
                            setTimeout(() => {
                                // 在 DarkReader 完成处理后,移除隐藏状态,显示内容
                                // 由于之前设置了 transition,这里会有一个平滑的淡入效果
                                document.body.classList.add('ready-to-show');
                            }, 200); // 200ms 的延迟时间,确保 DarkReader 有足够时间处理
                        } else {
                            // 如果用户没有启用深色模式,或者 DarkReader 未加载
                            // 直接显示内容,无需等待
                            document.body.classList.add('ready-to-show');
                        }
                    });
                </script>

至此,重启一遍网站服务即可。用另一台设备访问网站,可以看到深浅色模式切换导致的闪屏现象已经被彻底解决了。


📒访客记录

设置自定义日志格式

根据「用树莓派托管个人博客网站(一):搭建和部署Web应用」,nginx 的配置文件放置在 /etc/nginx/ 路径下。树莓派上运行的 Debian 系统约定:

  • /etc/nginx/sites-available/:存放所有可用的站点配置
  • /etc/nginx/sites-enabled/:存放已启用的站点配置(通过软链接激活)

首先在 /etc/nginx/sites-available/路径下创建站点配置。

bash
1
sudo vim /etc/nginx/sites-available/MyBlog

在配置的头部设置自定义的日志格式,增加了记录访客真实 IP 地址和所属国家的功能。

nginx
1
                2
                3
                4
                5
# 自定义日志格式:使用 CF-Connecting-IP 头部获取真实访客 IP
                log_format real_ip_log '$http_cf_connecting_ip - $remote_user [$time_local] '
                                       'country: $http_cf_ipcountry '
                                       '"$request" $status $body_bytes_sent '
                                       '"$http_referer" "$http_user_agent"';

然后,在服务器配置中设置使用自定义日志格式,并将真实 IP 转发给本地的 gunicorn 服务。

nginx
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
server {
                    # ...
                    # 使用包含真实 IP 的日志格式
                    access_log /var/log/nginx/access.log real_ip_log;
                    location / {
                        # ...
                        # X-Real-IP:用户的真实 IP(从 Cloudflare Tunnel 的 CF-Connecting-IP 头部获取)
                        proxy_set_header X-Real-IP $http_cf_connecting_ip;
                        # X-Forwarded-For:完整的 IP 链(使用真实 IP 而不是 127.0.0.1)
                        proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
                    }
                    # ...
                }

完整的配置文件内容如下所示:

nginx
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
server {
                    # 监听 80 端口(HTTP 默认端口)
                    listen 80;
                    # server_name 用于匹配域名,_ 是一个无效主机名,表示匹配任意域名或 IP 直接访问
                    server_name _;
                
                    # 使用包含真实 IP 的日志格式
                    access_log /var/log/nginx/access.log real_ip_log;
                    
                    # 所有以 / 开头的请求(即几乎所有动态页面),都转发给本地的 gunicorn 服务(运行在 127.0.0.1:8000)
                    location / {
                        proxy_pass http://127.0.0.1:8000;
                        # 传递原始请求信息给 Flask,否则 Flask 会以为所有请求都来自 127.0.0.1
                        # Host:用户访问的域名(如 192.168.1.100)
                        proxy_set_header Host $host;
                        # X-Real-IP:用户的真实 IP(从 Cloudflare Tunnel 的 CF-Connecting-IP 头部获取)
                        proxy_set_header X-Real-IP $http_cf_connecting_ip;
                        # X-Forwarded-For:完整的 IP 链(使用真实 IP 而不是 127.0.0.1)
                        proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
                    }
                
                    # 当用户请求 /static/style.css,Nginx 直接从磁盘读取文件返回,不经过 gunicorn/Flask
                    location /static {
                        alias /home/pi/MyBlog/static;
                        # 设置 HTTP 缓存头,让浏览器 30 天内不再重复下载
                        expires 30d;
                    }
                }

重载和测试 nginx 服务

接着,在不影响 nginx 服务的情况应用新的配置文件。

bash
1
                2
                3
                4
                5
                6
                7
                8
# 测试配置语法是否正确
                # 非常重要!避免因配置错误导致 nginx 崩溃
                # -t 表示 test,即测试
                sudo nginx -t
                
                # 重载 nginx
                # nginx 会平滑应用新配置,不中断现有连接
                sudo systemctl reload nginx

然后,用不同的设备对网站进行访问,在 /var/log/nginx/access.log 这一日志文件中就可以查看访客信息了。比方说,用以下命令可以进行实时查看:

bash
1
                2
# 查看 nginx 访问日志
                sudo tail -f /var/log/nginx/access.log

捕获原始 HTTP 请求

为了后续可以进一步修改 nginx 的日志格式,首先要对原始 HTTP 请求的请求头的内容有所了解。但是,nginx 无法直接将 HTTP 请求头输出。于是,我们只能使用 nginx 的 mirror 指令将 HTTP 请求复制一份,转发到别的端口查看。这一指令的优点在于不会影响 nginx 服务的正常运行。

第一步,改写 /etc/nginx/sites-available/路径下的 nginx 配置文件,如下所示:

nginx
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
                14
server {
                    # ...   
                    # 所有以 / 开头的请求(即几乎所有动态页面),都转发给本地的 gunicorn 服务(运行在 127.0.0.1:8000)
                    location / {
                        mirror /mirror;                   # ← 先声明要镜像
                        proxy_pass http://127.0.0.1:8000; # ← 主请求仍正常处理
                        # ...
                    }
                	location /mirror {
                        internal;
                        proxy_pass http://127.0.0.1:8080; # ← 转发到 8080 端口
                    }
                    # ...
                }

第二步,使用 netcat 实时捕获原始 HTTP 请求,命令如下:

bash
1
                2
# -l 就是 listen mode,表示监听 8080 端口
                nc -l 8080

第三步,用任意设备访问网站,然后观察输出的 HTTP 的请求,大概是下边这样的:

text
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
                14
                15
                16
                17
                18
                19
                20
                21
                22
                23
                24
GET /mirror HTTP/1.0
                Host: 127.0.0.1:8080
                Connection: close
                User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0
                Accept: text/html, application/xhtml+xml, application/xml; q=0.9, image/avif, image/webp, image/apng, */*; q=0.8, application/signed-exchange; v=b3; q=0.7
                Accept-Encoding: gzip, br
                Accept-Language: zh-CN,zh;q=0.9
                Cdn-Loop: cloudflare; loops=1
                Cf-Connecting-Ip: 4102:f000:3:6001:9c19:926c:4fb:7f69
                Cf-Ipcountry: CN
                Cf-Ray: 99fde712bf358cae-HKG
                Cf-Visitor: {"scheme":"https"}
                Cf-Warp-Tag-Id: 3c9b478c-d1a2-4374-91c2-a3ab700e0d32
                Priority: u=0, i
                Sec-Ch-Ua: "Chromium";v="142", "Microsoft Edge";v="142", "Not_A Brand";v="99"
                Sec-Ch-Ua-Mobile: ?0
                Sec-Ch-Ua-Platform: "Windows"
                Sec-Fetch-Dest: document
                Sec-Fetch-Mode: navigate
                Sec-Fetch-Site: none
                Sec-Fetch-User: ?1
                Upgrade-Insecure-Requests: 1
                X-Forwarded-For: 4102:f000:3:6001:9c19:926c:4fb:7f69
                X-Forwarded-Proto: https

以上各项内容有助于理解 HTTP 协议的工作原理,其包含的信息包括:

  1. Host: 127.0.0.1:8080 说明该请求被反向代理转发到了本地 8080 端口。
  2. User-Agent 显示访问者使用的是 Windows 10/11 系统上的 Microsoft Edge 浏览器(版本 142)。
  3. Accept-Language: zh-CN,zh;q=0.9 表示用户偏好简体中文界面。
  4. Cf-Connecting-Ip: 4102:f000:3:6001:9c19:926c:4fb:7f69 提供了访客的真实 IPv6 地址。
  5. Cf-Ipcountry: CN 表示访客位于中国。
  6. Cf-Ray: 99fde712bf358cae-HKG 是 Cloudflare 生成的唯一请求标识,后缀 HKG 表明该请求由其香港节点处理。
  7. Cf-Visitor: {"scheme":"https"} 说明原始用户是通过 HTTPS 访问网站的。

🔄内容同步自动化

rsync工具编写脚本

为了兼容树莓派上的 Linux 系统,编写自动化脚本的最佳选择是 Windows 系统上的 WSL(Windows Subsystem for Linux)。本地生成的静态博客文章位于 /mnt/d/MyBlogReleases/ 目录下。每次更新后,需要将其中所有的 .html 文件以及对应的 .assets/ 文件夹同步到远程树莓派上的指定路径 ~/MyBlog/posts/ 中。我们希望脚本可以通过局域网(LAN)或公网(WAN)两种方式连接树莓派。所以,需要给脚本设置一个输入参数:[lan|wan]

  • lan:通过局域网 IP(如 183.173.8.48)直连树莓派,速度快、延迟低。
  • wan:通过公网域名(如 ssh.hoigin.cn)连接,适用于远程部署场景。

此脚本可以利用 rsync 这个命令行工具实现高效的文件同步。rsync 的设计目标是最小化数据传输量。它不会简单地复制整个文件,而是通过比较源和目标的差异,仅传输发生变化的部分。默认情况下,如果文件大小相同且修改时间相同,rsync 认为此文件已经同步,直接跳过该文件;而如果文件大小不同或修改时间不同, rsync 会将整个文件重新传输。如果使用 --checksum 或者 -c 参数,rsync 会忽略时间戳和大小,对每个文件计算全文件的强校验和(如 MD5),仅当校验和与发送端文件的扫描结果匹配出现不同时才传输。这是一种称为 「rsync 算法」的高效差分同步算法(由 Andrew Tridgell 在 1996 年提出)。该算法结合了 校验和(checksum) 与 滚动哈希(rolling hash) 技术。

实际使用的时候,rsync 会在本地和远程之间建立连接(通常通过 SSH)。远程端启动一个 rsync --server 进程,负责读取目标文件并计算校验和。本地 rsync 进程作为发送端,执行上述算法,生成差异数据并发送。整个过程对用户透明,但效率远高于 scpcp

基于以上的要求,写好的脚本内容如下所示:

bash
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
#!/bin/bash
                # 指定脚本使用 Bash 解释器执行(标准 shebang)
                
                # ==============================
                # 配置区:用户可自定义的路径与密钥
                # ==============================
                
                # 本地源目录:存放待同步的 HTML 文件和 .assets 文件夹
                LOCAL_BASE_DIR="/mnt/d/MyBlogReleases/"
                # 远程目标目录(在树莓派上):
                REMOTE_TARGET_DIR="~/MyBlog/posts/"
                # SSH 私钥路径:用于免密登录树莓派
                SSH_KEY="$HOME/.ssh/id_ed25519"
                
                # ==============================
                # 网络模式选择:根据传入参数决定连接方式
                # ==============================
                
                # 检查第一个命令行参数($1)是否为 "lan" 或 "wan"
                if [ "$1" == "lan" ]; then
                    # 局域网模式:使用内网 IP 直连(速度快、延迟低)
                    SSH_HOST="pi@183.173.8.48"   # 树莓派在局域网中的固定 IP
                    SSH_PORT="22"                # SSH 默认端口(可省略,但显式写出便于未来扩展)
                elif [ "$1" == "wan" ]; then
                    # 公网模式:通过动态域名或公网 IP 连接(适用于远程部署)
                    SSH_HOST="pi@ssh.hoigin.cn"  # 公网可解析的域名(需已配置端口转发或内网穿透)
                    SSH_PORT="22"
                else
                    # 参数错误:打印用法说明并退出
                    echo "用法: $0 [lan|wan]"
                    echo "  lan: 局域网连接 (ssh pi@183.173.8.48)"
                    echo "  wan: 公网连接 (ssh pi@ssh.hoigin.cn)"
                    exit 1  # 返回非零状态码,表示脚本执行失败
                fi
                
                # ==============================
                # 开始同步流程
                # ==============================
                
                echo "正在连接到树莓派 ($SSH_HOST) 并同步文件..."
                # ------------------------------
                # 第一步:同步所有 .html 文件
                # ------------------------------
                echo "同步 HTML 文件..."
                # rsync 命令详解:
                # -a : 归档模式(等价于 -rlptgoD),保留符号链接、权限、时间戳等
                # -v : 详细输出(显示传输的文件名)
                # --progress : 显示传输进度(对大文件有用)
                # --delete : 删除目标端存在但源端已删除的文件(保持严格同步)
                # --chmod=D755,F644 : 
                #     D755 → 所有目录设为 rwxr-xr-x(所有者可读写执行,组和其他人只读执行)
                #     F644 → 所有文件设为 rw-r--r--(所有者可读写,组和其他人只读)
                # "$LOCAL_BASE_DIR"*.html : 匹配本地目录下所有 .html 文件(注意:若无匹配会报错)
                # "$SSH_HOST:$REMOTE_TARGET_DIR" : 远程目标路径(rsync 会自动通过 SSH 连接)
                rsync -av --progress --delete --chmod=D755,F644 "$LOCAL_BASE_DIR"*.html "$SSH_HOST:$REMOTE_TARGET_DIR"
                # ------------------------------
                # 第二步:同步所有 .assets 文件夹
                # ------------------------------
                echo "同步 .assets 文件夹..."
                # 注意:源路径末尾有斜杠 '/' → 表示同步 .assets/ 目录的 *内容*,
                #       而不是把每个 .assets 作为一个子目录创建。
                # 例如:post1.assets/ 的内容会被放入远程的 .assets/ 目录中(合并)
                # 若你希望保留每个 .assets 为独立子目录,请移除末尾斜杠,并调整目标路径。
                rsync -av --progress --delete --chmod=D755,F644 "$LOCAL_BASE_DIR"*.assets/ "$SSH_HOST:$REMOTE_TARGET_DIR/.assets/"
                
                # ==============================
                # 同步结果检查
                # ==============================
                
                # $? 获取上一条命令(即第二个 rsync)的退出状态码
                # 0 表示成功,非 0 表示失败
                if [ $? -eq 0 ]; then
                    echo "同步完成!"
                else
                    echo "同步失败,请检查连接或路径。"
                    exit 1  # 显式退出并返回错误状态
                fi

运行结果如下所示:

text
1
                2
                3
                4
                5
                6
                7
                8
                9
                10
                11
                12
                13
                14
                15
正在连接到树莓派 (pi@183.173.8.48) 并同步文件...
                同步 HTML 文件...
                sending incremental file list
                
                sent 361 bytes  received 12 bytes  248.67 bytes/sec
                total size is 712,591  speedup is 1,910.43
                同步 .assets 文件夹...
                sending incremental file list
                ./
                image-20251127125742510.png
                      1,240,302 100%   39.71MB/s    0:00:00 (xfr#1, to-chk=0/2)
                
                sent 1,240,764 bytes  received 38 bytes  496,320.80 bytes/sec
                total size is 1,240,302  speedup is 1.00
                同步完成!

输出一切正常,同步成功!需要注意的是,为了让同步完全自动化,建立 SSH 连接可以采用密钥对的登录方式,而不是用密码。配置此登录方式的方法参见「Cloudflare Tunnel + SSH 内网穿透指南」