记一次 hexo Blog 无原 markdown 的情况下迁移到 Ghost - V2EX
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 3361 次点击
    这是一个创建于 2417 天前的主题,其中的信息可能已经有所发展或是发生改变。

    记一次 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 挽尊
    bgm004
        3
    bgm004  
       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     Solana     5495 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 31ms UTC 06:43 PVG 14:43 LAX 23:43 JFK 02:43
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86