Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

博客历史演进

TL;DR

主要框架

附属模块

演进记录

Hexo 时期

  • 主题太多,选择太多,反而迷失了自我
  • 本身就讨厌 npm 和 node_modules,每次编译很烦,懒得折腾

VuePress 时期

  • 配置项太多了,非常麻烦
  • 同 hexo,用 npm 编译,看到 node_modules 就害怕
  • 编译产物比 hexo 还大

Mdbook 时期

动机

  • 左找右找,正好当时在学 Rust,看到 Mdbook 不错
  • 看了一下官方文档,配置项也特别少,适合自己这种懒人,就改为 mdbook 了
  • 刚开始是出于懒选择的 mdbook,后面发现自己做的工作其实不比其他静态网站生成框架少

困难

  • 切换成 mdbook 本身没什么困难,所有困难主要集中在附属模块上
  • mdbook 本身的困难只有一次,由于 mdbook 版本升级带来的 index.hbs 带来的预置代码变动
  • 详见这篇博客和本博客 Github 上源码关于 index.hbs 带来的更改,另外,Rust 语言圣经也遇到了这个问题,其更改也可以参考

添加附属模块

TOC

  • 切换到 mdbook 首先问题就是没有文章目录
    • 习惯了先浏览文章大纲,建立框架再补充文章细节,很不习惯
  • 浏览了网上所有关于 mdbook 的 toc 方法,都不太满意
  • 大多数是在每篇文章顶部添加 toc 链接
    • 这会占据文章的内容空间,不仅不优雅,每次想跳到另一个章节的时候,还要滚回文章顶部,非常麻烦
  • 正巧当时在学 rust,Rust 语言圣经的 mdbook 里就有 toc 侧边栏,很好奇其实现,就去翻源码
    • 源码在 assets/custom.js,里面的代码大部分都是自定义,toc 侧边栏实现大概是遍历网页的 tagName
    • 还有一些其他附加功能,比如当前页面停留三十秒以上,视为已读过,mdbook 的侧边栏,该文章标题会变灰作为提示
  • 照着实现 toc 和研究 hbs 代码花了很多时间(因为当时不懂 hbs,其实现在也不懂)
    • 途中还遇到了一个怎么都解决不了的难题,最后发现是文件名为 head.hbs,mdbook 会视该文件仅为页面的 head 部分,改成 index.hbs 就好了

评论

  • 评论模块也是参照 Rust 语言圣经,使用 giscus 实现,不过直到现在都没有人用(挺正常的)
    • 评论模块没有太大的问题
    • 之前还附带有 view counter,但是负责 count 的那个网站似乎被炸了,无法完成功能,所以也就把相关代码删了

最近更新

  • mdbook 不按照时间进行倒序排列,而是按照书一样分章节组织
  • 所以改动了哪里也不知道,对于读者来说可能有点烦,每次都要跑到 github 上看代码提交
  • 写脚本花了一段时间,也犯了比较多的错,直到现在这个脚本都有 bug,而且不通用,不过也懒得改了
    • 每次出 bug 的时候,比如 sed 找不到代表代码块的 ``` 标志,就会直接把 last-updated.md 全删了,再 add && commit,主打一个懒

站内搜索

  • mdbook 的中文搜索和 telegram 的中文搜索一样烂,自己写了太多东西记不到,只记得关键词,搜不到就很烦
    • mdbook 的搜索,根据西方文字自身的空格作为分词符,但中文根本不用空格这种东西,就没法分词
  • 机缘巧合找到了 docsearch,申请了一下,通过了,官方也给了教程怎么搭建
    • 不过 docsearch 很长一段时间我都没用到,因为搜索框死活出不来
    • 本来人就懒,就一直在摆烂,后面有一次有时间了,也实在忍不了搜索之殇了,就好好排查了一下问题
    • 结果发现是 index.hbs 里面,关于 docsearch 的配置少了一个逗号,排查出来自己都想笑,怎么犯了这么低级的错误
  • docsearch 是 algolia 的子服务,要先注册 algolia,然后一步一步配置
    • 甚至要自己配置 algolia 的 crawler(爬虫),自定义爬取的内容,才能加到 algolia 的倒排索引里面,也才能搜索到
    • 本来就懒,简直逆了天了,照着网上稍微配置了一下,自定义了一下就没管了
    • 有一些配置上的 bug 到现在都还没修……
  • docsearch 用起来倒是挺好用
  • 配置在附录,如果有需要可以参考一下

收录网站

  • 后来想把网站加到搜索引擎里,想着可能会帮到别人
    • 一进 Google 和 Bing 的站长工具,又要在 html 的 head 里面加东西,很麻烦
    • 加好了之后,又说需要站点地图才能爬网,当时连站点地图都不知道是啥
  • 随便搜了一个生成站点地图的工具 static-sitemap-cli,还好用起来不复杂
    • 可惜又是依赖 node,用 js 写的工具……
    • 每次本地编译完了之后,就会用这个工具生成站点地图,都在脚本里,自动化一下就可以了
  • 注意,随着文章越来越多,站点地图 sitemap.xml 和 sitemap.txt 会越来越大,而且 xml 文件每次编译里面的时间都会变,所以 last-updated.md 会越拖越长,这点在写脚本的时候尤其要注意

部署

通过分支部署

  • mdbook 博客搭建好的时候,github pages 的“通过 github actions 部署”还是 experimental(实验性的),采取了主流方案:Deploy from branch(通过分支部署)
    • 但是当时也写了一个 github actions 每次推送的时候自动编译,然后推送到 gh-pages 分支上,就不用自己本地编译了
    • 本地编译一遍主要是因为站点地图和生成 last-updated.md 需要,如果不做这两个工作可以直接推送到 remote
  • 当时用的别人的 actions,自己也没注意到 git 会 tracing 编译后的产物,这些产物都在 gh-pages 里,导致仓库越来越大,仅 .git 文件夹就有 800 多 MB,加上编译产物就有 1G 之大,被迫瘦身

通过 Github Actions 部署

  • 原计划是清理 gh-pages 分支,删除所有 git history
    • 也确实这么做了,从 834MB 减到了 26.8MB,详见这篇博客
    • 但是发现这个时候“通过 Github Actions 部署”已经不再是实验性的了
    • 经过提醒,于是从 Deploy from branch 改为 Github Actions,所以 gh-pages 分支也就用不到了,直接删除
  • 参照 mdbook wiki 里的官方部署教程,自己重写了 Github Actions 文件

使用 Cloudflare Pages

  • 这个想法是源于,经常给国内没法翻墙的朋友发博客内容很不方便,半天转不出来,想方便一下国内的朋友
    • 找了很多静态网站托管服务,要么被墙要么要钱,很搞人,自己甚至还开了一个月腾讯云的静态网站托管服务,结果域名要备案,还要上传 ssl 证书,纯纯大冤种
    • gitea + drone CI/CD,腾讯云,vercel,netlify,Gitee Pages 都试过了,没啥用
      • 最哈皮的还是 Gitee,这玩意儿居然会从源代码里面审查违规内容
      • 而且规则不透明,上一次部署能通过的文章,下一次部署就通过不了了
      • 甚至林清玄的文章都是违规内容
      • 也不告诉你哪儿错了,就告诉你这篇文章有问题,叫人自己排查
      • 开源中国的反馈 repository,里面至少一半多的 issue 都是反应自己无法部署
  • Cloudflare Pages 刚开始用了一下,但是没有 mdbook 的选项,也就不了了之,结果后面搜出来有一种比较 dirty 的做法可以曲线救国
    • Cloudflare Pages 直接导入 Github 仓库
      • 构建配置的框架预设选无
      • 构建命令填 curl -L https://github.com/rust-lang/mdBook/releases/download/v0.4.37/mdbook-v0.4.37-x86_64-unknown-linux-gnu.tar.gz | tar xvz && ./mdbook build
      • 构建输出目录填 book
    • 本解决方案参考这篇同样使用 mdbook 作为博客的博文
      • Cloudflare Pages 官方说支持 mdbook 构建,但实际上构建选项里并没有 mdbook
      • 有人在对此的 issue 里提出了一个比较 dirty 的做法,还真有用……
      • 为什么说 dirty,因为这个方法本质上是在 Cloudflare Pages 的构建服务器上,暴力拉 mdbook 的 binary 下来,构建命令当成注入到别人服务器里的 hack 命令用,看一下构建 log 就知道了(构建日志详见附录)
        • 重点在 00:14:26.223 那一行,构建命令实际上是一个 user command,按理来说可以乱填,这里拉 mdbook binary 下来就是不太合规的做法
      • 不过能用就行……
      • Cloudflare Pages 在国内的访问速度其实一般,不过好歹能访问,不像 Github Pages
  • 感谢一只昀喵辛勤的指导,受教了

附录

Algolia Crawer 配置

new Crawler({
  // ⚠️ 三条硬性兼容要求(改动前请先看 assets/algolia-crawler.md 第三节):
  //   1. 第 1 行必须就是 new Crawler({,前面不能有注释或代码(algolia/docsearch#2190)
  //   2. 不用模板字符串
  //   3. 配置内部不写嵌套函数/回调:编辑器的静态检查解析不了嵌套作用域里的
  //      参数绑定,会把回调参数报成未定义标识符(曾把 .each((_, el) => ...) 的
  //      el 标红)。所以下面全部用普通下标循环 + 直筒代码,没有闭包。
  appId: 'GX9RTL51BH',
  // 保持你现有 Crawler 的 apiKey(不要把这个可写 key 提交进公开仓库)
  apiKey: 'PlaceHolder',
  rateLimit: 8,
  maxDepth: 10,
  maxUrls: 5000,
  startUrls: [
    'https://tinysnow.github.io',
    // ⚠️ toc.html 必须留在 startUrls 里:站点静态 HTML 里首页只有
    // print.html / last-updated.html 两个站内链接、没有任何 prev/next,
    // 而 toc.html 里有 2359 条站内链接,是全站唯一的「发现入口」。
    // 少了它,实测覆盖率会从 2360 个页面掉到 297 个(只剩「生活碎笔」那条章节链)。
    // 它自己不会进索引:见下面的 pathsToMatch 排除 + recordExtractor 里的 SKIP_PATHS。
    'https://tinysnow.github.io/toc.html',
  ],
  renderJavaScript: false,
  // 故意不用站点的 sitemap:它的 2360 条 URL 全部不带 .html(如 /个人简介),
  // 而 GitHub Pages 对 /个人简介 和 /个人简介.html 都直接返回 200(不是 301 跳转),
  // 用它会变成每个页面被索引两份。全站覆盖靠 toc.html 这个入口 + 站内链接。
  sitemaps: [],
  ignoreCanonicalTo: false,
  // 排除不需要下载的页面:print.html 是整本书合订本(19MB),404.html 没有内容。
  // ⚠️ 千万不要把 toc.html 放进来——它是发现入口,排除它等于掐掉全站链接。
  exclusionPatterns: [
    'https://tinysnow.github.io/print.html',
    'https://tinysnow.github.io/404.html',
  ],
  discoveryPatterns: ['https://tinysnow.github.io/**'],
  schedule: 'at 13:14 on Tuesday',
  actions: [
    {
      indexName: 'tinysnowio',
      // 抓但不建索引的三个页面:toc.html 只用来发现链接,print/404 不是内容
      pathsToMatch: [
        'https://tinysnow.github.io/**',
        '!https://tinysnow.github.io/toc.html',
        '!https://tinysnow.github.io/print.html',
        '!https://tinysnow.github.io/404.html',
      ],
      recordExtractor: ({ url, $, contentLength, helpers }) => {
        /* ============================ 开关 ============================ */
        const SKIP_PATHS = ['/print.html', '/toc.html', '/404.html'];
        const MAX_PAGE_BYTES = 4 * 1024 * 1024;    // 超过这个体积的页面跳过
        const INDEX_IMAGE_ALT = true;              // 把图片 alt 并入正文
        const PAGE_TITLE_FALLBACK = true;          // 没有 h1 的页面用 <title> 补 lvl1
        const PRE_SEGMENT = true;                  // 额外写一份「按词」的属性
        const SEGMENT_MAX_CHARS = 3000;            // 分词副本截断长度
        const MAX_RECORDS_PER_PAGE = 700;          // 爬虫单页硬上限是 750
        const MERGE_MAX_CHARS = 1200;
        const LEVELS = ['lvl0', 'lvl1', 'lvl2', 'lvl3', 'lvl4', 'lvl5', 'lvl6'];
        const HAS_CJK = /[\u3400-\u9FFF\uF900-\uFAFF]/;

        // 中文分词器:ICU 词典,与 Algolia 引擎声称使用的 CJK 词典同源。
        // 环境不支持 Intl.Segmenter 时退化为不分词,不影响爬取。
        let segmenter = null;
        try {
          if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
            segmenter = new Intl.Segmenter('zh-Hans', { granularity: 'word' });
          }
        } catch (e) { /* 忽略:走不分词分支 */ }

        /* ========================== 跳过规则 ========================== */
        if (SKIP_PATHS.indexOf(url.pathname) !== -1) return [];
        if (contentLength && contentLength > MAX_PAGE_BYTES) return [];
        // 抽取主体不存在就直接跳过,避免抽出菜单栏之类的垃圾
        if ($('main').length === 0 && $('#mdbook-content').length === 0) return [];

        /* ======================= 图片 alt 并入正文 ======================= */
        // 用下标循环,不用回调,避免嵌套作用域
        if (INDEX_IMAGE_ALT) {
          const images = $('main img[alt]');
          for (let i = 0; i < images.length; i++) {
            const image = images[i];
            const alt = ($(image).attr('alt') || '').trim();
            // 只保留有信息量的 alt:含连续中文,或含连续英文且不像文件名。
            // 实测本站 114 个去重 alt 里有 21 个是 image-20260531144550242、
            // Snipaste_2023-10-22 这类文件名,靠下面这行挡掉。
            const meaningful =
              /[\u4e00-\u9fff]{2,}/.test(alt) ||
              (/[A-Za-z]{2,}/.test(alt) && !/[_0-9]/.test(alt));
            if (!meaningful) continue;
            const safe = alt.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
            $(image).after('<p>' + safe + '</p>');
          }
        }

        /* =========================== 抽取记录 =========================== */
        const records = helpers.docsearch({
          recordProps: {
            // 故意不用站点标题 h1.menu-title:否则「微雪」会命中每一个页面
            lvl0: {
              selectors: '',
              defaultValue: '博客',
            },
            // 页面标题 = <main> 里的 h1。
            // ⚠️ 千万不要把 'head > title' 之类交给 helper 当兜底:它会在所有候选
            //    选择器里取「文档顺序最靠前」的那个,<title> 在 <head> 里永远排第一,
            //    结果 lvl1 全变成「重装系统 - 微雪博客」——标题带上站点名,而且
            //    「微雪」「博客」会命中每一个页面(实测 2342/2342)。
            //    没有 h1 的那 23 个页面改在下面用 <title> 补,见 PAGE_TITLE_FALLBACK。
            lvl1: ['main h1', '#mdbook-content h1'],
            lvl2: ['main h2', '#mdbook-content h2'],
            lvl3: ['main h3', '#mdbook-content h3'],
            lvl4: ['main h4', '#mdbook-content h4'],
            lvl5: ['main h5', '#mdbook-content h5'],
            lvl6: ['main h6', '#mdbook-content h6'],
            // 正文:只取 <main> 里的块级元素。去掉了 span/a/em/strong/ul/ol
            // (同一句话会被抽 3~4 遍),补上 td/th(表格)与 dd/dt,
            // code 同时覆盖块级和行内代码。
            content: [
              'main p, main li, main td, main th, main code, main dd, main dt',
              '#mdbook-content p, #mdbook-content li, #mdbook-content td, #mdbook-content th, #mdbook-content code, #mdbook-content dd, #mdbook-content dt',
            ],
            // 语言交给 helper 写进每条记录
            lang: { defaultValue: 'zh-CN' },
          },
          aggregateContent: true,
          indexHeadings: true,
          recordVersion: 'v3',
        });

        /* ================== 单页记录数保护(内联,无函数)================== */
        // 爬虫单页超过 750 条记录会直接报 Extractors returned too many records。
        // 超过阈值时把「同一锚点、类型为 content」的相邻记录合并,标题原样保留;
        // 在副本上合并,不改动 helper 返回的原对象。
        let finalRecords = records;
        if (records.length > MAX_RECORDS_PER_PAGE) {
          const packed = [];
          let pending = null;
          for (let i = 0; i < records.length; i++) {
            const record = records[i];
            const isContent = record.type === 'content' && typeof record.content === 'string';
            const canMerge =
              pending &&
              isContent &&
              pending.url === record.url &&
              pending.content.length + record.content.length < MERGE_MAX_CHARS;
            if (canMerge) {
              pending.content += '\n' + record.content;
              continue;
            }
            if (pending) packed.push(pending);
            if (isContent) {
              pending = Object.assign({}, record);
            } else {
              pending = null;
              packed.push(record);
            }
          }
          if (pending) packed.push(pending);
          finalRecords = packed;
        }

        /* ================ 写一份按词切分的副本(内联,无函数)================ */
        // 先给没有标题的记录补 lvl1:这类页面 <main> 里没有 h1(实测 23 个),
        // 只能用 <title> 兜底,并去掉「 - 微雪博客」这个站点名后缀。
        // 放在分词之前,这样 hierarchy_words.lvl1 也会一起生成。
        if (PAGE_TITLE_FALLBACK) {
          const pageTitle = ($('head > title').text() || '').replace(/ - 微雪博客$/, '').trim();
          if (pageTitle) {
            for (let i = 0; i < finalRecords.length; i++) {
              const titleHierarchy = finalRecords[i].hierarchy;
              if (titleHierarchy && !titleHierarchy.lvl1) {
                titleHierarchy.lvl1 = pageTitle;
              }
            }
          }
        }

        // 把所有要分词的文本收集起来,统一分词后再写回,
        // 这样正文和各级标题共用同一段逻辑,且不需要定义函数。
        if (PRE_SEGMENT) {
          const texts = [];
          const targets = [];
          for (let i = 0; i < finalRecords.length; i++) {
            const record = finalRecords[i];
            if (record.content) {
              texts.push(String(record.content));
              targets.push([i, '']);
            }
            const hierarchy = record.hierarchy || {};
            for (let j = 0; j < LEVELS.length; j++) {
              if (hierarchy[LEVELS[j]]) {
                texts.push(String(hierarchy[LEVELS[j]]));
                targets.push([i, LEVELS[j]]);
              }
            }
          }

          const segmented = [];
          for (let i = 0; i < texts.length; i++) {
            const text = texts[i];
            let result = text;
            if (segmenter && HAS_CJK.test(text)) {
              // 只保留词元,用空格连接:重装系统 8->7 → 重装 系统 8 7
              const parts = Array.from(segmenter.segment(text));
              const words = [];
              for (let k = 0; k < parts.length; k++) {
                if (parts[k].isWordLike) words.push(parts[k].segment);
              }
              result = words.join(' ');
              if (result.length > SEGMENT_MAX_CHARS) result = result.slice(0, SEGMENT_MAX_CHARS);
            }
            segmented.push(result);
          }

          for (let i = 0; i < finalRecords.length; i++) {
            finalRecords[i].hierarchy_words = {};
          }
          for (let i = 0; i < targets.length; i++) {
            const record = finalRecords[targets[i][0]];
            const level = targets[i][1];
            if (level) {
              record.hierarchy_words[level] = segmented[i];
            } else {
              record.content_words = segmented[i];
            }
          }
        }

        return finalRecords;
      },
    },
  ],
  safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 100 } },
  initialIndexSettings: {
    tinysnowio: {
      attributesForFaceting: ['type', 'lang'],
      attributesToRetrieve: [
        'hierarchy',
        'content',
        'anchor',
        'url',
        'url_without_anchor',
        'type',
        'lang',
      ],
      attributesToHighlight: ['hierarchy', 'content'],
      attributesToSnippet: ['content:10'],
      camelCaseAttributes: ['hierarchy', 'content'],
      // 前 8 项是按词切分的副本(优先命中),后 8 项是按字的原文(兜底)。
      // 若把上面的 PRE_SEGMENT 改成 false,请删掉前 8 项。
      searchableAttributes: [
        'unordered(hierarchy_words.lvl0)',
        'unordered(hierarchy_words.lvl1)',
        'unordered(hierarchy_words.lvl2)',
        'unordered(hierarchy_words.lvl3)',
        'unordered(hierarchy_words.lvl4)',
        'unordered(hierarchy_words.lvl5)',
        'unordered(hierarchy_words.lvl6)',
        'content_words',
        'unordered(hierarchy.lvl0)',
        'unordered(hierarchy.lvl1)',
        'unordered(hierarchy.lvl2)',
        'unordered(hierarchy.lvl3)',
        'unordered(hierarchy.lvl4)',
        'unordered(hierarchy.lvl5)',
        'unordered(hierarchy.lvl6)',
        'content',
      ],
      distinct: true,
      // 按「页面」去重:同一篇文章只出一条命中,避免单页刷屏
      // (原配置是 'url',含锚点,等于按段落去重)
      attributeForDistinct: 'url_without_anchor',
      customRanking: ['desc(weight.pageRank)', 'desc(weight.level)', 'asc(weight.position)'],
      ranking: ['words', 'filters', 'typo', 'attribute', 'proximity', 'exact', 'custom'],
      highlightPreTag: '<span class="algolia-docsearch-suggestion--highlight">',
      highlightPostTag: '</span>',
      minWordSizefor1Typo: 3,
      minWordSizefor2Typos: 7,
      allowTyposOnNumericTokens: false,
      minProximity: 1,
      ignorePlurals: true,
      // 中文按词切分的开关(原配置完全没有这两项)
      indexLanguages: ['zh', 'en'],
      queryLanguages: ['zh', 'en'],
      advancedSyntax: true,
      attributeCriteriaComputedByMinProximity: true,
      removeWordsIfNoResults: 'allOptional',
    },
  },
});

Cloudflare Pages 构建日志

00:14:23.159	Cloning repository...
00:14:24.014	From https://github.com/TinySnow/TinySnow.github.io
00:14:24.015	 * branch            3685b9c14d244ab2a2888d820486e261e5174aea -> FETCH_HEAD
00:14:24.015	
00:14:24.139	HEAD is now at 3685b9c Added some Pharmacology content
00:14:24.139	
00:14:24.222	
00:14:24.222	Using v2 root directory strategy
00:14:24.250	Success: Finished cloning repository files
00:14:25.960	Checking for configuration in a wrangler.toml configuration file (BETA)
00:14:25.960	
00:14:26.071	No wrangler.toml file found. Continuing.
00:14:26.223	Detected the following tools from environment: 
00:14:26.223	Executing user command: curl -L https://github.com/rust-lang/mdBook/releases/download/v0.4.30/mdbook-v0.4.30-x86_64-unknown-linux-gnu.tar.gz | tar xvz && ./mdbook build
00:14:26.245	  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
00:14:26.246	                                 Dload  Upload   Total   Spent    Left  Speed
00:14:26.455	
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
00:14:26.831	mdbook
00:14:27.065	
  0 5031k    0 32768    0     0  59463      0  0:01:26 --:--:--  0:01:26 59463
100 5031k  100 5031k    0     0  6130k      0 --:--:-- --:--:-- --:--:-- 18.1M
00:14:27.136	2024-05-02 16:14:27 [INFO] (mdbook::book): Book building has started
00:14:27.146	2024-05-02 16:14:27 [INFO] (mdbook::book): Running the html backend
00:14:36.384	Finished
00:14:36.385	Note: No functions dir at /functions found. Skipping.
00:14:36.385	Validating asset output directory
00:14:37.224	Deploying your site to Cloudflare's global network...
00:14:42.448	Uploading... (1254/1254)
00:14:42.449	✨ Success! Uploaded 0 files (1254 already uploaded) (0.65 sec)
00:14:42.449	
00:14:43.060	✨ Upload complete!
00:14:45.569	Success: Assets published!
00:14:46.752	Success: Your site was deployed!