記一次Blog遷移到Ghost(內含乾貨)

relsoul發表於2019-02-14

歡迎來互換友鏈:記一次Blog遷移到Ghost

記一次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結構進行分析。

實現的最終效果如下 原文

記一次Blog遷移到Ghost(內含乾貨)

轉換後

記一次Blog遷移到Ghost(內含乾貨)

頁面的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 relsoul@outlook.com --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

記一次Blog遷移到Ghost(內含乾貨)
記一次Blog遷移到Ghost(內含乾貨)
先給出驗證的POST, 這裡要注意的一點就是client_secret這個欄位!!!真的很噁心,因為你基本上在後臺是找不到的,因為官方也說了AdminApi其實目前是私有的,所以你需要在資料庫找 具體的表看下圖
記一次Blog遷移到Ghost(內含乾貨)
拿到這個值後就可以請求獲取accessToken了
記一次Blog遷移到Ghost(內含乾貨)
拿到這串值後則可以開始呼叫POST介面了

POST文章

記一次Blog遷移到Ghost(內含乾貨)
Authorization欄位這裡的話前面的字串是固定的Bearer <access-token>,接著看BODY這項
記一次Blog遷移到Ghost(內含乾貨)
我把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

相關文章