V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
推荐关注
Meteor
JSLint - a JavaScript code quality tool
jsFiddle
D3.js
WebStorm
推荐书目
JavaScript 权威指南第 5 版
Closure: The Definitive Guide
relsoul
V2EX  ›  JavaScript

记一次 hexo Blog 无原 markdown 的情况下迁移到 Ghost

  •  
  •   relsoul · 2019-03-04 15:38:34 +08:00 · 3065 次点击
    这是一个创建于 2126 天前的主题,其中的信息可能已经有所发展或是发生改变。

    记一次 Blog 迁移到 Ghost

    之前的用了 Hexo 搭建了 Blog,不过由于某次的操作失误导致 Hexo 本地的 source 源文件全部丢失.留下的只有网页的 Html 文件,众所周知,Hexo 是一个本地编译部署类型的 Blog 系统,个人感觉这种类型的 Blog 特别不稳定,如果本地出了一些问题那么线上就 GG 了,当然,使用 git 是可以管理源文件,但是源文件里又包含很多图片的情况下,download 和 upload 一次的时间也会比较长,虽然说这几年都流行这种类型的 Blog,但个人看来还是 WEB 比较实在。

    Blog 选型

    自己对 Blog 也玩了一些,不算特别多,主要是 wordpress(php),这次在看 Blog 系统的时候,第一时间考虑的是 wordpress,说起来当时也是 wordpress 比较早的使用患者了,wordpress 的扩展性强,尤其是目前版本的 wordpress,结合主题文件和自定义 function,完全可以玩出很多花样,并且支持各种插件 balabala,最新的版本还支持 REST-API,开发起来也极为方便,那么为什么我没有选用 wordpress?之前做了一些 wordpress 的研究,发现在结合某些主题的时候,wordpress 会极慢(php7.2,ssd,i7-7700hq,16gb),本地跑都极慢,当然这个锅 wordpress 肯定不背的,但是我在自己开发主题的情况下,写了一个 rest-api

       /**
         * 构建导航
         * @param $res          所有的 nav 导航 list
         * @param $hash      承载导航的 hash 表
         */
        private function buildNav(&$res,&$hash){
            //组装导航
            foreach($hash as $i =>$value){
                $id = $value->ID;
    
                $b =$this->findAndDelete($res,$id);
                $value->sub= $b;
    
                // 是否有子目录
                if(count($b)>0){
                    $this->buildNav($res,$value->sub);
                }
            }
        }
    
        public function getNav($request){
            $menu_name = 'main-menu';   // 获取主导航位置
            $locations = get_nav_menu_locations();
            $menu_id = $locations[ $menu_name ] ;
            $menu = wp_get_nav_menu_object($menu_id);    //根据 locations 反查 menu
            $res = wp_get_nav_menu_items($menu->term_id);
    
            // 组装嵌套的导航,
            $hash = $this->findAndDelete($res,0);
    
            $this->buildNav($res,$hash);
    
            return rest_ensure_response( $hash );
        }
    }
    

    代码比较简单,获取后台的主导航,循环遍历组装成嵌套的数组结构,我在后台新建了 3 个导航,结果调用这个 API(PHP5.6)的情况需要花费 500ms-1s,在 PHP7 的情况需要花费 200-500ms,这个波动太大了,数据库链接地址也用了 127.0.0.1,再加上自己不怎么会拍黄片(CURD 级别而已),所以虽然爱的深沉,最后还是弃用了。

    那么可选择的还有 go 语言的那款和 ghost 了,本人虽然很想去学 Go 语言,奈何头发已不多,还是选择了自己熟悉的 node.js 使用了 ghost,这样后续开发起来也比较方便。

    老 Blog 的 html2markdown

    因为只有 html 文件了,那么这个时候得想办法把 html 转 markdown,本来想简单点,说话的方式...咳咳,试用了市面上的 html2markdown,虽然早知不会达到理想的效果,当然结果也是不出所料的,故只能自己根据文本规则去写一套转换器了.

    html 分析

    首先利用http-server本地搭建起一套静态服务器,接着对网页的 html 结构进行分析。

    实现的最终效果如下 原文

    转换后

    页面的 title 是.article-title此 class 的文本,页面的所有内容的 wrap 是.article-entry,其中需要转化的 markdown 的 html 就是.article-entry >*,得知了这些信息和结构后就开始着手写转化规则了,比如h2 -> ## h2;首先建立 rule 列表,写入常用的标签,这里的$是 nodejs 的 cheerio,elem 是 htmlparse2 转换出来的,所以在浏览器的某些属性是没办法在 nodejs 看到的

    const ruleFunc = {
        h1: function ($, elem) {
                return `# ${$(elem).text()} \r\n`;
            },
        img: function ($, elem) {
            return `![${$(elem).text()}](${$(elem).attr('src')}) \r\n`;
        },
        ....
    }
    

    当然,这些只是常用的标签,光这些标签还不够,比如遇到文本节点类型,举个例子

    <p>
    我要
    <a href="/">我的</a>
    滋味
    </p>
    

    那么你不能单纯的获取 p.text()而是要去遍历其内部还包含了哪些标签,并且转换出来,比如上述的例子就包含了

    文本节点(text)
    a 标签(a)
    文本节点(text)
    

    对应转化出来的 markdown 应该是

    我要
    [我的](/)
    滋味
    

    还好,由 markdown 生成出来的 html 不算特别坑爹,什么意思呢,不会 p 标签里面嵌套乱七八糟的东西(其实这跟 hexo 主题有关,还好我用的主题比较叼,代码什么的都很规范),那么这个时候就要开始建立 p 标签的遍历规则

     p: function ($, elem) {
            let markdown = '';
            const $subElem = $(elem).contents(); // 获取当前 p 标签下的所有子节点
            $subElem.each((index, subElem) => {
                const type = subElem.type; // 当前子节点的 type 是 text 还是 tag
                let name = subElem.name || type; // name 属性===nodeName 也就是当前标签名
                name = name.toLowerCase();
                if (ruleFunc[name]) { // 是否在当前解析规则找到
                    let res = ruleFunc[name]($, subElem); // 如果找到的话则递归解析
                    if (name != 'br' || name != 'text') { // 如果当前节点不是 br 或者文本节点 都把\r\n 给去掉,要不然会出现本来一行的文本因为中间加了某些内容会换行
                        res = res.replace(/\r\n/gi, '');
                    }
                    markdown += res;
                }
            });
            return markdown + '\r\n'; // \r\n 为换行符
        },
        
    

    那么 p 标签的解析规则写完后,要开始考虑 ul 和 ol 这种序号类型了,不过这种类型的也有嵌套的

    - web
    -   - js
    -   - css
    -   - html
    - backend
    -   -   node.js
    

    像这种嵌套类型的也需要去用递归处理一下

        ul: function ($, elem) {
            const name = elem.name.toLowerCase();
            return __list({$, elem, type: name})
        },
        ol: function ($, elem) {
            const name = elem.name.toLowerCase();
            return __list({$, elem, type: name})
        },
    
        /**
     * @param splitStr 默认的开始符是 -
     * @param {*} param0 
     */
    function __list({$, elem, type = 'ul', splitStr = '-', index = 0}) {
        let subNodeName = 'li'; // 默认的子节点是 li 实际上 ol,ul 的子节点都是 li
        let markdown = ``;
        splitStr += `\t`; // 默认的分隔符是 制表符
        if (type == 'ol') {
            splitStr = `${index}.\t` // 如果是 ol 类型的 则是从 0 开始的 index 实际上这一步有点多余,在下文有做重新替换
        }
        $(elem).find(`> ${subNodeName}`).each((subIndex, subElem) => { 
            const $subList = $(subElem).find(type); //当前子节点下面是否有 ul || ol 标签?
            if ($subList.length <= 0) { 
                if (type == 'ol') {
                    splitStr = splitStr.replace(index, index + 1); // 如果是 ol 标签 则开始符号为 1. 2. 3. 这种类型的
                    index++;
                }
                return markdown += `${splitStr} ${$(subElem).text()} \r\n`
            } else { 
                // 如果存在 ul || ol 则进行二次递归处理
                let nextSplitStr = splitStr + '-';
                if (type == 'ol') {
                    nextSplitStr = splitStr.replace(index, index + 1);
                }
                const res = __list({$, elem: $subList, type, splitStr: nextSplitStr, index: index + 1}); // 递归处理当前内部的 ul 节点
                markdown += res;
            }
        });
        return markdown;
    }
    

    接着处理代码类型的,这里要注意的就是转义和换行,要不然 ghost 的 markdown 不识别

        figure:function ($,elem) {
            const $line = $(elem).find('.code pre .line');
            let text = '';
            $line.each((index,elem)=>{
                text+=`${$(elem).text()} \r\n`;
            });
            return ` \`\`\` \r\n ${text} \`\`\` \r\n---`
        },
    

    那么做完这两步后,基本上解析规则已经完成了 80%,什么?你说 table 和序列图类型?...这个坑就等着你们来填啦,我的 Blog 很少用到这两种类型的。

    抓取 html

    抓取 html 这里则可以使用 request+cheerio 来处理,抓取我 Blog 中的所有文章,并且建立 urlArray,然后遍历解析就行

    async function getUrl(url) {
    
        let list = []
        const options = {
            uri: url,
            transform: function (body) {
                return cheerio.load(body);
            }
        };
        console.info(`获取 URL:${url} done`);
        const $ = await rp(options);
        let $urlList = $('.archives-wrap .archive-article-title');
        $urlList.each((index, elem) => {
            list.push($(elem).attr('href'))
        });
        return list;
    }
    
    async function start() {
        let list = [];
        let url = `http://127.0.0.1:8080/archives/`;
        list.push(...await getUrl(url));
    
        for (let i = 2; i <=9; i++) {
            let currentUrl = url +'page/'+ i;
            list.push(...await getUrl(currentUrl));
        }
    
        console.log('所有页面获取完毕',list);
    
        for(let i of list){
           await html2Markdown({url:`http://127.0.0.1:8080${encodeURI(i)}`})
        }
    }
    

    上述要注意的就是,抓取到的 href 如果是中文的话,是不会 url 编码的,所以在发起请求的时候最好额外处理一下,因为熟悉自己的 Blog,所以有些数值都写死啦~

    ghost 的搭建

    ghost GitHub

    这里我要单独说一下,ghost 的搭建是恶心到我了,虽然能够快速搭建起来,但是如果想简简单单的线上使用,那就是图样图森破了,因为需要额外的配置,

    安装

    npm install ghost-cli -g // ghost 管理 cli
    ghost install local // 正式安装
    

    运行

    ghost start
    

    第一次运行成功后先别急着打开 web 填信息,先去配置一下 mysql 模式,sqlit 后期扩展性太差了。

    找到 ghost 安装目录下生成的config.development.json配置

      "database": {
        "client": "mysql",
        "connection": {
          "host": "127.0.0.1",
          "port": 3306,
          "user": "root",
          "password": "123456",
          "database": "testghost"
        }
      },
    
      ------
    
      "url": "https://relsoul.com/",
    

    把 database 替换为上述的 mysql 配置,然后把 url 替换为线上 url,接着执行ghost restart即可

    NGINX+SSL

    这里利用 https://letsencrypt.org 来获取免费的 SSL 证书 结合 NGINX 来配置(服务器为 centos7)

    首先需要申请两个证书 一个是*.relsoul.com 一个是 relsoul.com

    安装

        yum install -y epel-release
        wget https://dl.eff.org/certbot-auto --no-check-certificate
        chmod +x ./certbot-auto
    

    申请通配符 参考此文章进行通配符申请

     ./certbot-auto certonly  -d *.relsoul.com --manual --preferred-challenges dns --server https://acme-v02.api.letsencrypt.org/directory 
    

    申请单个证书 参考此篇文章进行单个申请

    ./certbot-auto certonly --manual --email [email protected] --agree-tos --no-eff-email -w /home/wwwroot/challenges/ -d relsoul.com
    

    注意的是一定要加上--manual,不知道为啥如果不用手动模式,自动的话不会生成验证文件到我的根目录,按照命令行的交互提示手动添加验证文件到网站目录。

    配置

    这里直接给出 nginx 的配置,基本上比较简单,80 端口访问默认跳转 443 端口就行

    server {
        listen 80;
        server_name www.relsoul.com relsoul.com;
        location ^~ /.well-known/acme-challenge/ { 
            alias /home/wwwroot/challenges/; # 这一步很重要,验证文件的目录放置的
            try_files $uri =404;
        }
    # enforce https
        location / {
            return 301 https://www.relsoul.com$request_uri;
        }
    }
    
    server {
        listen 443  ssl http2;
    #listen [::]:80;
        server_name relsoul.com;
        ssl_certificate /etc/letsencrypt/live/relsoul.com-0001/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/relsoul.com-0001/privkey.pem;
    # Example SSL/TLS configuration. Please read into the manual of NGINX before applying these.
        ssl_session_timeout 5m;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
        keepalive_timeout 70;
        ssl_stapling on;
        ssl_stapling_verify on;
        index index.html index.htm index.php default.html default.htm default.php;
        # root /home/wwwroot/ghost;
        location / {
            proxy_pass http://127.0.0.1:9891; # ghost 后台端口
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Host $server_name;
            proxy_read_timeout 1200s;
    # used for view/edit office file via Office Online Server
            client_max_body_size 0;
        }
    #error_page   404   /404.html;
        # location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
        #     expires 30d;
        # }
        # location ~ .*\.(js|css)?$ {
        #     expires 12h;
        # }
        include none.conf;
        access_log off;
    }
    
    server {
        listen 443  ssl http2;
    #listen [::]:80;
        server_name  www.relsoul.com;
        ssl_certificate /etc/letsencrypt/live/relsoul.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/relsoul.com/privkey.pem;
    # Example SSL/TLS configuration. Please read into the manual of NGINX before applying these.
        ssl_session_timeout 5m;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        ssl_prefer_server_ciphers on;
        keepalive_timeout 70;
        ssl_stapling on;
        ssl_stapling_verify on;
        index index.html index.htm index.php default.html default.htm default.php;
        # root /home/wwwroot/ghost;
        location / {
            proxy_pass http://127.0.0.1:9891; # ghost 后台端口,进行反向代理
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Host $server_name;
            proxy_read_timeout 1200s;
    # used for view/edit office file via Office Online Server
            client_max_body_size 0;
        }
    #error_page   404   /404.html;
        # location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
        #     expires 30d;
        # }
        # location ~ .*\.(js|css)?$ {
        #     expires 12h;
        # }
        include none.conf;
        access_log off;
    }
    

    做完上面几步后就可以访问网站进行设置了,默认的设置地址为http://relsoul.com/ghost

    导入文章至 ghost

    ghost 的 API 是有点蛋疼的,首先 ghost 有两种 API,一种是PublicApi,一种是AdminApi

    PublicApi 目前只支持读,也就是 Get,不支持 POST,而 AdminApi 则是处于改版的阶段,不过还是能用的,官方也没具体说什么时候废除,这里只针对 AdminApi 进行说明,因为 PublicApi 的调用太简单了,文档也比较全,AdminApi 就蛋疼了

    获取验证 Token

    先给出验证的 POST, 这里要注意的一点就是client_secret这个字段!!!真的很恶心,因为你基本上在后台是找不到的,因为官方也说了 AdminApi 其实目前是私有的,所以你需要在数据库找 具体的表看下图 拿到这个值后就可以请求获取 accessToken 了 拿到这串值后则可以开始调用 POST 接口了

    POST 文章

    Authorization字段这里的话前面的字符串是固定的Bearer <access-token>,接着看 BODY 这项 我把 JSON 单独拿出来说了

    {
        "posts":[
            {
                "title":"tt19", // 文章的 title
                "tags":[ // tags 必须为此格式,可以是具体 tag 的 id,也可以是未存在 tag 的 name,会自动给你新建的,我推荐用{name:xxx} 来做上传
                    {
                        "id":"5c6432badb8806671eaa915c"
                    },
                    {
                        "name":"test2"
                    }
                ],
                "mobiledoc":"{"version":"0.3.1","atoms":[],"cards":[["markdown",{"markdown":"# ok\n\n```json\n{\n ok: \"ok\"\n}\n```\n\n> xd\n \n<ul>\n <li>aa</li>\n <li>bb</li>\n</ul>\n\nTest"}]],"markups":[],"sections":[[10,0],[1,"p",[]]]}",
                "status":"published", // 设置状态为发布 
                "published_at":"2019-02-13T14:25:58.000Z", // 发布时间
                "published_by":"1", // 默认为 1 就行
                "created_at":"2016-11-21T15:42:40.000Z" // 创建时间
            }
        ]
    }
    

    到了这里还有一个比较重要的字段就是mobiledoc,对,提交不是 markdown,也不是 html,而是要符合mobiledoc规范的,我一开始也懵逼了,以为需要我调用此库把 markdown 再转一次,后来发现是我想复杂了,其实只需要

    {
                "version": "0.3.1",
                "atoms": [],
                "cards": [["markdown", {"markdown": markdown}]],
                "markups": [],
                "sections": [[10, 0], [1, "p", []]]
            };
    

    按照这种格式拼装一下,变量markdown是转换出来的 markdown,拼接好后切记转为 JSON 字符串JSON.stringify(mobiledoc),那么了解了提交格式等,接下来就可以开始写代码了

    NODEJS 提交文章

    async function postBlog({markdown, title, tags, time}) {
        const mobiledoc =
            {
                "version": "0.3.1",
                "atoms": [],
                "cards": [["markdown", {"markdown": markdown}]],
                "markups": [],
                "sections": [[10, 0], [1, "p", []]]
            };
    
        var options = {
            method: 'POST',
            uri: 'https://www.relsoul.com/ghost/api/v0.1/posts/',
            body: {
                "posts": [{
                    "title": title,
                    "mobiledoc": JSON.stringify(mobiledoc),
                    "status": "published",
                    "published_at": time,
                    "published_by": "1",
                    tags: tags,
                    "created_at": time,
                    "created_by": time
                }]
            },
            headers: {
                Accept: "application/json",
                "Content-Type": "application/json",
                Authorization: "Bearer token"
            },
            json: true // Automatically stringifies the body to JSON
        };
    
        const res = await rp(options);
        if (res['posts']) {
            console.log('插入成功', title)
        } else {
            console.error('插入失败', res);
        }
    
    }
    

    结尾

    最终成果参考 Blog 源代码 GitHub

    6 条回复    2019-10-03 16:40:18 +08:00
    jingyulong
        1
    jingyulong  
       2019-03-04 15:44:46 +08:00
    都是成年人了,要学会自己回复,打破 0 评论。
    relsoul
        2
    relsoul  
    OP
       2019-03-04 15:47:37 +08:00
    @jingyulong 挽尊
    Track13
        3
    Track13  
       2019-03-04 15:49:56 +08:00 via Android
    hexo 放 onedrive 里安全点
    relsoul
        4
    relsoul  
    OP
       2019-03-04 15:51:18 +08:00
    @Track13 已经晚了,之前的 markdown 已经消失在二次元黑洞里了 o(╥﹏╥)o
    sunocean
        5
    sunocean  
       2019-04-10 16:53:38 +08:00
    老哥,这类 blog 的精髓是 git , 正常操作是一个分支放网页一个分支放源码。
    Wyane
        6
    Wyane  
       2019-10-03 16:40:18 +08:00
    厉害了,搜索 markdown 文件导入 wordpress 搜到这里。。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2787 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 07:45 · PVG 15:45 · LAX 23:45 · JFK 02:45
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.