Blog迁移的一些事

2023年08月31日

最近把Axis Blog从Ghost迁移到了HUGO,经过了一段时间的完善与小修小补,总算是接近尾声了,顺便还整理了图片、博文和评论。发现有些评论没及时回复,真的抱歉。

slug调整

Blog建立初期,写了就发,完全不知道slug这回事。Ghost的默认行为是将汉语的标题转换成拼音并且用短横线分隔作为slug,一眼也看不来是什么含义。

逐渐发现很多博客的url都有自己的规则,才知道slug指的是带有语义的url。后来会在发布博文之前会手动修改Ghost自动生成的slug,一般是"时间-描述",新发布的文章得以规范,以前的就随它去了,懒得改,随意修改也会导致评论URL改变无法加载评论。

迁移工具ghostToHugo会把slug作为markdown的文件名,看着千奇百怪的文件名终于还是下决心统一改了一遍🤣。

评论区

评论区基于Waline,之前因为DNS配置的关系坏了一段时间,无法留言,目前已经修复了。

Waline的评论读取基于文章的URL,改slug自然会导致部分文章的URL变更,评论无法加载。只能把数据导出,根据新老URL进行替换。好在我评论并不多,否则可能要写个脚本来做这件事。

Waline的评论默认存在Learncloud中,控制台左边结构化数据->有个Class叫做Comment中保存了全部的评论。我勾选了它导里面的所有数据并下载到本地,这是个JSON文件。修改替换URL之后再导回了Learncloud就大功告成了。不过有以下几个点要注意:

  • 导出后要删掉原来的Comment对象,导入时设置Class名称为Comment,否则无法导入。
  • 通过勾选->下载得到的JSON数据中,时间是以2017-05-02T14:42:40.000Z格式存储的,没有带类型信息。导回去之时这一列的类型变成了String,后续再新增评论时会报类型转换错误。createdAt、insertedAt、updatedAt这三列会受到影响。解决方法就是导入时补全时间类型,如下
 {
    "QQAvatar": "",
    "comment": "<p>哈哈哈,有缘人!</p>",
    "createdAt": "2017-05-21T12:29:54.000Z",
    "insertedAt": "2017-05-21T12:29:54.000Z",
    "ip": "",
    "link": "",
    "mail": "",
    "nick": "Kasai",
    "objectId": "3317233467",
    "pid": "3317216815",
    "rid": "3317216815",
    "status": "approved",
    "ua": "",
    "updatedAt": "2017-05-21T12:29:54.000Z",
    "url": "/posts/20170512-songjiang-landscape/"
  }

改成

   {
    "QQAvatar": "",
    "comment": "<p>哈哈哈,有缘人!</p>",
    "createdAt": {
      "__type": "Date",
      "iso": "2017-05-21T12:29:54.000Z"
    },
    "insertedAt": {
      "__type": "Date",
      "iso": "2017-05-21T12:29:54.000Z"
    },
    "ip": "",
    "link": "",
    "mail": "",
    "nick": "Kasai",
    "objectId": "3317233467",
    "pid": "3317216815",
    "rid": "3317216815",
    "status": "approved",
    "ua": "",
    "updatedAt": {
      "__type": "Date",
      "iso": "2017-05-21T12:29:54.000Z"
    },
    "url": "/posts/20170512-songjiang-landscape/"
  }

这可能需要编写一些脚本来处理数据。

后来发现使用“导入导出”->“数据导出”够避免类型丢失的问题。

图片存储

Axis迁移了几次,从搬瓦工到vultr,再签到vultr东京机房,再到这次使用HUGO重建,图片一直都是个问题。

先是使用了Ghost的上传功能;后来怕流量不足,使用了七牛云;再后来,将一部分全尺寸大图通过500px.com/图虫外链的方式引入,形成了本地、七牛云、图床缝合怪;到了2021年引入了backblaze,新的博文图片全部上传到backblaze,更多地缝合。

折腾了一大圈。

图片作为我的Blog的主要流量消耗源,解决方案也并不是说哪种好或者哪种不好,而是随着图片尺寸、VPS提供商的免费流量、CDN等因素综合考虑的。

Ghost的图片上传功能其实不错,会自动生成多个尺寸的缩略图,还保留了原图,就我这点图片量和vultr提供的2TB免费流量,完全可以一用。但是它的多图添加完全不像单图片一样支持添加链接,而是直接上传到vps上,常见的对象存储倒也都支持,backblaze、七牛云,都有adapter,我这样的有时候从500px或者图虫外链过来的就没什么办法,所以我经常手写html代码。另外就是它的编辑器,插入图片的方式总让我觉得我的文章被打断了,中间强行插入了一个相册,然后再在下面插入一个文章片段。

换到了HUGO的过程中,我在调试主题的时候居然直接把backblaze一天的流量刷没了😂。1GB流量不套CDN实在是太小了,而我这样没有备案的BLOG自然不可能用到国内的CDN,cloud flare又实在是太慢。思来想去,既然VPS已经有了2TB的流量,存储空间是25GB还比blackblaze的免费10GB大,那干脆还是用VPS的本地存储吧,如果是“大片”,那依然引用图虫的链接。

又回到了使用backblaze之前的状态。

backblaze我一直是手动上传,并且按照博文的slug作为目录,所以只要把图片打包下载,扔到static目录系下,再统一把图片的域名修改成一个本站的路径就OK了,也没浪费什么功夫。倒是整理博文,补全缺失的图片,花了很长时间,参考七牛云跨区同步找回了因测试域名过期而丢失的图片,很高兴。

HUGO只是个静态网站生成器,并不会对图片进行处理。写了个nodejs的脚本来压缩整个static目录下的图片,自动压缩并生成宽度为1080p的小尺寸图片或者是webp格式的图片,不过本Blog的webp尚未实装。1080p的图片的文件大小已经很小了,我也并未生成更小尺寸的缩略图。

const sharp = require('sharp');
const fs = require('fs').promises;
const path = require('path');

const MAX_EDGE_SIZE = 1080;
const QUALITY = 80;
const DIRECTORY_PATH = './static/images'; 

const logFileName = `logs/log_${new Date().toISOString().replace(/[:.]/g, '_')}.txt`;

async function fileExists(filePath) {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}

async function processDirectory(directory) {
  const entries = await fs.readdir(directory, { withFileTypes: true });

  for (const entry of entries) {
    const fullPath = path.join(directory, entry.name);

    if (entry.isDirectory()) {
      await processDirectory(fullPath);
    } else if (entry.isFile()) {
      await processFile(fullPath);
    }
  }
}

async function processFile(filePath) {
  const fileExt = path.extname(filePath).toLowerCase();
  const fileName = path.basename(filePath, fileExt);
  const directory = path.dirname(filePath);

  // 排除以 "_c" 结尾的文件
  if (fileName.endsWith('_c')) {
    console.log(`Skipping file: ${filePath}`);
    return;
  }

  const compressedFilePath = path.join(directory, `${fileName}_c${fileExt}`);
  const webpFilePath = path.join(directory, `${fileName}.webp`);

  if (['.jpg', '.jpeg', '.png'].includes(fileExt)) {
    console.log(`Processing file: ${filePath}`);

    try {
      // Condition 1: Generate compressed file if it does not exist
      if (!await fileExists(compressedFilePath)) {
        const { width, height } = await sharp(filePath).metadata();
        
        let newWidth, newHeight;

        if (Math.max(width, height) > MAX_EDGE_SIZE) {
          const scalingFactor = MAX_EDGE_SIZE / Math.max(width, height);
          newWidth = Math.floor(width * scalingFactor);
          newHeight = Math.floor(height * scalingFactor);
        } else {
          newWidth = width;
          newHeight = height;
        }

        await sharp(filePath)
          .resize(newWidth, newHeight)
          .jpeg({ quality: QUALITY })
          .toFile(compressedFilePath);
      }

      // Condition 2: Generate webp only if it does not exist
      // if (!await fileExists(webpFilePath)) {
        // await sharp(compressedFilePath)
          // .webp({ quality: QUALITY })
          // .toFile(webpFilePath);
      // }
      
    } catch (err) {
      console.error(`Error processing ${filePath}: ${err}`);
      await fs.appendFile(logFileName, `Error processing ${filePath}: ${err}\n`);
    }
  }
}

(async () => {
  await fs.mkdir('logs', { recursive: true });
  await processDirectory(DIRECTORY_PATH);
})();

多图展示

然后写了个shortcode作为多图添加的快捷方式。多图展示最麻烦是如何排布,像是500px那样的瀑布流ChatGPT给我的解答是需要复杂的计算。我找了几个瀑布流的开源库,也都要填入宽度,对于我这样图片尺寸混乱情况并没有很好的效果。索性直接用flex布局,设置成可换行。

外层是gallery.html

{{ $id := .Get "id"}}
{{ $desc := .Get "desc"  | safeHTML}}
  <div class="axis-gallery">
      <figure class="axis-gallery-inner">
          {{ with $id}}
          <div class="axis-gallery-images" id="{{ $id }}">
          {{ else }}
          <div class="axis-gallery-images">
          {{ end }}
              {{ .Inner }}
          </div>
          {{ with $desc}}
          <hr>
          <figcaption>{{ $desc }}</figcaption>
          {{ end }}
      </figure>
  </div>

内部是img.html,通过接收入参中的w参数,在生成静态网站时设置样式指定宽度的百分比,对于未传参数的图片直接用98%作为宽度值。使用媒体查询,当屏幕尺寸过小时,不再设置宽度,直接98%。

另外,使用了_c作为压缩后的预览图地址,这需要配合上面的js脚本使用,否则会找不到图片。

{{ $src := .Get "src" }}
{{ $ext := path.Ext $src }}
{{ $base := strings.TrimSuffix $ext $src }}
{{ $webp := printf "%s.webp" $base }}
{{ $compressed := printf "%s_c%s" $base $ext }}


{{ $width := .Get "w" | default "98" -}}
{{ with .Parent }}
  <div  class="axis-gallery-image img-width-{{$width}}">

          {{ if or (strings.HasPrefix $src "https")  (strings.HasPrefix $src "http") }}
            <img src="{{ $src }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox  style="max-width: 100%; height: auto;">
          {{ else }}
          <!-- <source srcset="{{ $webp }}" type="image/webp"> -->
            <img src="{{ $compressed }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox style="max-width: 100%; height: auto;">
          {{ end }}
  
  </div>
{{- else -}}
  <div  class="axis-gallery-image" style="width: 98%;">
    <!-- <source srcset="{{ $webp }}" type="image/webp"> -->
    <img src="{{ $compressed }}" data-ngsrc="{{ $src }}" data-nanogallery2-lightbox>
  </div>
{{ end }}
<style>
.img-width-{{$width}}{
    width: 100%;
  }

@media (min-width: 576px) {
  .img-width-{{$width}}{
    width: {{$width}}%;
  }
}
</style>

然后是一些CSS,很简单,给flex布局设置了flex-wrap: wrap;来允许换行。当宽度之和超过100%时,自动换行。

.axis-gallery-image{
    display: flex;
    width: 98%;
}

.axis-gallery{
    max-width: $content-max-width;
    width: 100%;
    
    .axis-gallery-inner{

        figcaption{
            font-size: 0.8rem;
            color: var(--extra-info);
            margin: 0 0.5em;
        }

        hr{
            margin: 0.2em;
        }

        .axis-gallery-images{
            margin: 0 auto;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-wrap: wrap; /* 允许换行 */
            .axis-gallery-image{
                margin: 0.5em auto;
            }

        }
    }
}

写作的时候需要简单计算宽度值,通过手动调整宽度值来使得几张照片的高度大致相等,并不算麻烦。

用法是这样。

我追寻着她从江苏如东的风车海岸到西北腹地的广袤沙漠
{{< gallery desc="沙丘上简洁的s曲线" >}}
    {{< img src="https://photo.tuchong.com/1358305/f/310303977.jpg">}}
    {{< /gallery >}}

    {{< gallery desc="沙漠上空的一朵小云" >}}
        {{< img src="https://photo.tuchong.com/1358305/f/434429562.jpg">}}
    {{< /gallery >}}

    {{< gallery desc="阿拉善左旗的大漠风光" >}}
        {{< img src="https://photo.tuchong.com/1358305/f/280092526.jpg" w="96">}}

        {{< img src="https://photo.tuchong.com/1358305/f/118611490.jpg" w="34">}}
        {{< img src="https://photo.tuchong.com/1358305/f/130538892.jpg" w="30">}}
        {{< img src="https://photo.tuchong.com/1358305/f/210034271.jpg" w="30">}}

        {{< img src="https://photo.tuchong.com/1358305/f/373612056.jpg" w="18">}}
        {{< img src="https://photo.tuchong.com/1358305/f/553377931.jpg" w="40">}}
        {{< img src="https://photo.tuchong.com/1358305/f/279961121.jpg" w="40">}}
    {{< /gallery >}}

可以设置每张图片的宽度来达到不同的排布效果,在img标签上配置data-ngsrc="{{ $src }}" data-nanogallery2-lightbox来初始化nanogallery2的lightbox,点击后展示大图,参考效果

为vscode添加一个snippet,可以快速输入。

  "hugo gallery shortcode": {
		"prefix": "agallery",
		"body": [
			"{{< gallery desc=\"$1图组描述\" >}}",
			"   {{< img src=\"$2图片链接\" w=\"$3宽度占比\">}}",
			"{{< /gallery >}}"
		],
		"description": "Insert hugo gallery shortcode"
	}

并在vscode的设置中启用对markdown文件的提示

    "[markdown]":  {
        "editor.quickSuggestions": {
            "comments": "on",
            "strings": "on",
            "other": "on"
        }
    },

这样就解本地图片的尺寸问题和博文中照片的排列问题。像这样定义自己代码块,并且快捷输入,Ghost的编辑器是做不到的。

同理,还可以自定义各种各样的shortcode,可以参考来写一些好玩的 Hugo 短代码吧,这是个非常精美的Blog。

自动构建

开始的几个版本,我手动执行HUGO,然后zip压缩后用SFTP上传到服务器,这个阶段主要是在测试HUGO的功能。正式使用时就感受到这一点都不Geek,也很麻烦,图片多了之后全量上传也很浪费时间。最后还是搞了一个自动化构建发布。

乞丐版CI/CD了属于是。

  1. 在家中的NAS上初始化了一个git bare仓库,将写完的代码push到bare仓库。这也省去了备份的功夫。
  2. 在NAS上的仓库中添加post-receive脚本,脚本内容是接收到内容就checkout到一个临时目录,调用hugo构建站点。
  3. 在构建输出的public目录中创建一个新的本地git仓库,push到VPS上的仓库中,这一步也是在上面的post-receive脚本中进行。
  4. 在VPS上初始化一个git仓库,用于接收3中的推送。同时也添加一个post-receive脚本,接收到内容时checkout到/var/www/html/目录中。

搞这么复杂的初衷是希望通过git来管理,只上传改动过的文件。

结果事与愿违,跑了一下发现原来HUGO并不是增量构建的,每次构建完后git commit时发现所有文件都变了😩。于是把3、4步改成了用rsync,在windows的gitbash中安装了rsync,然后用rsync –checksum同步到vps的/var/www/html中,这样既能通过校验和按需同步,也简化了步骤,最重要的是节省了VPS上用于版本管理的空间。

当然了,其实完全可以把bare仓库放到VPS上,流程会更简单。但这样又要存源文件,又要编译输出,相当于存储了双份的图片,剩下的20GB空间不久就会捉襟见肘,想了想还是算了。

于是写作流程就搞完了,也比之前用Ghost的编辑器稍微Geek一点点🤣。

文章发布日期

过了几天,又碰到了两个关于时间的问题。

如果不小心把文章的frontmatter数据中的date设置成一个未来的时间,那运行hugo -D server是看不到这篇文章的。这个问题坑了我很久。一度认为必须用hugo new posts/xxx.md生成出来的文章才能被HUGO识别,这不科学。其实是执行hugo new posts/xxx.md生成出来的文章用的是本机的时区和当前时间,不会超过当前时间。

另一个问题是ghostToHugo这个工具会把ghost中的创建时间(还是发布时间)作为frontmatter的date字段,是形如2016-08-02T19:05:49Z的UTC时间。在HUGO中,即使在config.toml中设置了时区,或者是date字段本身带了一个Z后缀表示这是UTC时间,到了模板中使用{{ .Date.Format "2006年01月02日" }}时,如果不指定时区那还是会将2016-08-02T19:05:49Z中的Z忽略,作为本地时间来处理。这就导致了我所有博文的时间都提前了8个小时。我一直在疑惑,我的第一篇文章应该是生日当天写的,怎么还早了一天呢。

于是又只能靠脚本来读取所有的文章,统一将时间修改成UTC+8的时间。

const fs = require('fs');

const matter = require('gray-matter');
const toml = require('@iarna/toml');
const {glob} = require("glob");

const dir = "./content/**/*.md"

// 用于将UTC日期转换为UTC+8
function convertToUTC8(dateString) {
  console.log(dateString + '转换为UTC+8');
  const date = new Date(dateString);
  if (date.toISOString().endsWith('Z')) {
    date.setHours(date.getHours() + 8);
  
    const iso = date.toISOString()
    const newDate = iso.slice(0, 19) + '+08:00';
    return newDate;
  }
  return date;
}

// 遍历指定目录中的所有Markdown文件
glob(dir).then(function (files) {

  files.forEach(file => {
    // 读取文件内容
    const fileContent = fs.readFileSync(file, 'utf8');
    
    try {
          // 解析Front Matter
    const content = matter(fileContent,{
      engines: {
        toml: toml.parse.bind(toml),
      },
      language: 'toml',
      delimiters: '+++'
    });
      
      const data = content.data
      if (data.date) {
        data.date = convertToUTC8(data.date);
      }
      
      const updatedContent = matter.stringify(content, data,{
        engines: {
          toml: {
            parse: toml.parse.bind(toml),
            stringify: toml.stringify.bind(toml)
          } 
        },
        language: 'toml',
        delimiters: '+++'
      });
      // remove quota
      const fixedFrontMatter = updatedContent.replace(/date = "([^"]+)"/, 'date = $1');
      console.log(`Updated ${file} with ${fixedFrontMatter}` );
      fs.writeFileSync(file, fixedFrontMatter, 'utf8');
    } catch (error) {
      console.log(file,error);
    }
  });
});

这样一来,类似2016-08-02T19:05:49Z的时间都就都改成了2016-08-03T03:05:49+08:00了。

PS:不得感叹AI的强大,很多不知道怎么搜索的问题只要问ChatGPT就可以了,还完全不用看令人头大的HUGO文档。