阿里云服务器 篇十:自动定时备份优快云博客内容

样例网站

https://blog.cnfaq.cn

系列文章

阿里云服务器 篇一:申请和初始化
阿里云服务器 篇二:搭建静态网站
阿里云服务器 篇三:提交搜索引擎收录
阿里云服务器 篇四:404页面模板
阿里云服务器 篇五:短链服务网站
阿里云服务器 篇六:GitHub镜像网站
阿里云服务器 篇七:服务器热备份/定时备份
阿里云服务器 篇八:图片展示和分享网站(纯静态,数据信息和展示页面分离)
阿里云服务器 篇九:个人博客类网站

支持以Markdown文件进行独立存储的博客网站

备份使用的博客网站需要支持以Markdown文件作为存储系统,具体的选型和搭建参见 阿里云服务器 篇九:个人博客类网站

获取优快云文章内容

优快云的导出API接口

优快云的导出的操作入口非常隐蔽,需要点击编辑文章,然后在文章的编辑页面的右上方的工具栏点击“导出”图标,才能看到“导出为Markdown文件”和“导出为HTML文件”的选项。(且仅支持Markdown格式编辑的文章,富文本格式编辑的文本没有导出选项)

观察Chrome开发者模式下的网络请求,并没有发现有导出API接口。只是在每次点击导出文件的选项时,都会触发一个类似blob:https://editor.youkuaiyun.com/fcce6636-c4db-4dfd-b2a2-89f696a6e4f8的下载链接,注意:该链接中的下载地址中一长串编码为即时生成、即时生效,每次点击都会变化且不可复用

所以,实现上需要使用无头浏览器框架技术进行模拟点击和下载。

优快云 编辑原文接口

对于Markdown格式文章,在编辑文章页面(即https://editor.youkuaiyun.com/md/?articleId=143426797),有编辑时获取原文数据的接口:https://bizapi.youkuaiyun.com/blog-console-api/v3/editor/getArticle?id=143426797&model_type=,其接口可以获取Markdown原文内容即markdowncontent字段和渲染样式后的HTML内容(即预览内容)即content字段,样例Response如下:

{
    "code": 200,
    "msg": "success",
    "data": {
        "article_id": "143426797",
        "title": "\u963f\u91cc\u4e91\u670d\u52a1\u5668 \u7bc7\u4e5d\uff1a\u4e2a\u4eba\u535a\u5ba2\u7c7b\u7f51\u7ad9",
        "description": "Web\u670d\u52a1\u5668...",
        "content": "<p><\/p><div class=\"toc\"><h3>\u6587\u7ae0\u76ee\u5f55<\/h3>...\n\n",
        "markdowncontent": "@[TOC]\n\n### \u7cfb\u5217\u6587\u7ae0\n...\n\n\n",
        "tags": "flatnotes,\u4e2a\u4eba\u535a\u5ba2,CMS,\u7b14\u8bb0,Wiki",
        "categories": "\u659c\u6760\u4eba\u751f",
        "type": "original",
        "status": 1,
        "read_type": "public",
        "reason": "",
        "resource_url": "",
        "resource_id": "",
        "original_link": "",
        "authorized_status": false,
        "check_original": false,
        "editor_type": 1,
        "plan": [],
        "vote_id": 0,
        "scheduled_time": 0,
        "level": "1"
    }
}

对于富文本格式文章,在编辑文章页面(即https://mp.youkuaiyun.com/mp_blog/creation/editor/7700322),有编辑时获取原文数据的接口:https://bizapi.youkuaiyun.com/blog-console-api/v1/editor/getArticle?id=7700322,其接口可以获取渲染样式后的HTML内容即content字段,且保留有markdowncontent字段只是内容为空,样例Response如下:

{
    "code": 200,
    "msg": "success",
    "data": {
        "article_id": "7700322",
        "title": "\u53ea\u9700\u503e\u542c",
        "description": "\u6c9f\u901a\u4e4b\u672c...",
        "content": "\r\n<p><br>\r\n<\/p>\r\n<p>\u6c9f\u901a...",
        "markdowncontent": "",
        "tags": "\u60c5\u611f",
        "categories": "\u611f\u60c5,\u6709\u5f85\u66f4\u65b0",
        "type": "original",
        "status": 1,
        "read_type": "public",
        "reason": "",
        "resource_url": "",
        "resource_id": "",
        "original_link": "",
        "authorized_status": false,
        "check_original": false,
        "editor_type": 0,
        "plan": [],
        "vote_id": 0,
        "scheduled_time": 0,
        "level": "0"
    }
}

注意:

  1. 富文本格式使用的是v1版本的API,经对比,其Response结构和v3版本是完全一致的。
  2. 2个版本的API都在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
其他方案

其他比较常见的方案都是直接访问文章地址后对获取的HTML内容进行处理,比如html2mdhtml2txthtml2pdf等是有现成的开源转换项目的。

获取文章ID列表

文章列表页面

在文章列表页面(即https://blog.youkuaiyun.com/zhiyuan411?type=blog),有获取文章列表数据的接口:https://blog.youkuaiyun.com/community/home-api/v1/get-business-list?page=1&size=20&businessType=blog&orderby=&noMore=false&year=&month=&username=zhiyuan411,其接口可以获取文章ID即articleId字段,文章地址即url字段,文章编辑地址即editUrl字段,不过其postTime字段是文章初次发表时间而不是最后编辑时间,样例Response如下:

{
    "code": 200,
    "message": "success",
    "traceId": "e6b01d44-ef8f-49a2-9a0d-cab62e3a4d12",
    "data": {
        "list": [
            {
                "articleId": 81351176,
                "title": "Living",
                "description": "First2.开心就好3.无拘无束(自由)4.Be Nice(但行好事)Others",
                "url": "https://xiaobai.blog.youkuaiyun.com/article/details/81351176",
                "type": 1,
                "top": true,
                "forcePlan": false,
                "viewCount": 552,
                "commentCount": 1,
                "editUrl": "https://editor.youkuaiyun.com/md?articleId=81351176",
                "postTime": "2018-08-02 03:01:20",
                "diggCount": 0,
                "formatTime": "2018.08.02",
                "picList": [],
                "collectCount": 0
            },
            ...
        ],
        "total": 552
    }
}

注意:

  1. 该API接口直接使用URL访问时会被提示“很抱歉,您访问的网站已开启安全防御,请完成 “安全验证” 后继续访问”,需要手工拖动滑块完成拼图后才能继续访问,且一段时间内不需要再次拖动滑块完成拼图就可以直接访问。其验证方式应该仍然是在请求header内采用了某些验证字段。
  2. 其请求参数size可以指定获取的文章数量,服务器限制的上限大概是在200条。
  3. 浏览页面时,其获取文章列表的下一页数据的方式是向下滑动页面(没有显式的页码)。
内容管理页面

在内容管理页面(即https://mp.youkuaiyun.com/mp_blog/manage/article),有获取文章列表数据的接口:https://bizapi.youkuaiyun.com/blog/phoenix/console/v1/article/list?page=2&pageSize=20,其接口可以获取文章ID即articleId字段,不过其postTime字段是文章初次发表时间而不是最后编辑时间,样例Response如下:

{
    "code": 200,
    "message": "success",
    "traceId": "bc70a761-7336-4e8b-9b6e-6b5391d3ff98",
    "data": {
        "count": {
            "all": 556,
            "private": 1,
            "deleted": 44,
            "original": 0,
            "enable": 552,
            "audit": 0,
            "draft": 3
        },
        "list": [
            {
                "articleId": "142531570",
                "title": "降准降息一揽子措施点燃 A 股激情,4% 大涨之后趋势深度剖析",
                "postTime": "2024-09-25 22:22:21",
                "viewCount": "212",
                "commentCount": "0",
                "commentAuth": "2",
                "isTop": "0",
                "status": "1",
                "username": "zhiyuan411",
                "type": "1",
                "isNeedFans": "0",
                "isNeedVip": "0",
                "isVipArticle": false,
                "editorType": 1,
                "isRecommend": false,
                "totalExposures": 3193,
                "fansCount": 0,
                "diggCount": 4,
                "collectCount": 0,
                "coverImage": [
                    "https://i-blog.csdnimg.cn/direct/68bd5a6b42b74def804f5dc0369f71da.png"
                ],
                "voteId": 0,
                "isLock": false,
                "deprecated": false,
                "isShowFeedback": false,
                "complaintArticleId": 0,
                "complaintTitle": null,
                "complaintUrl": null,
                "complaintArticleStatus": null,
                "isComplaint": null,
                "complaintStatus": null,
                "complaintOrderNumber": null,
                "scheduledTime": 0,
                "downloadShareDataUrl": "https://blog.youkuaiyun.com/phoenix/web/v1/share/download-share-data?articleId=142531570",
                "plan": null,
                "qualityScore": 93,
                "qualitySpecification": "https://blog.youkuaiyun.com/u010280923/article/details/131449478"
            },
            ...
        ],
        "page": 2,
        "size": 20,
        "listStatus": "all",
        "total": 556,
        "couponGuidanceModule": {
            "btnImgUrl": "https://img-home.csdnimg.cn/images/20240229105408.png",
            "bubbleImgUrl": "https://img-home.csdnimg.cn/images/20240229105332.png",
            "url": "https://mp.youkuaiyun.com/mp_blog/manage/traffic"
        }
    }
}

注意:

  1. 该API在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
  2. 默认的结果是包含草稿状态的文章,可以使用Response中的status字段来筛选(为1表示已公开发表文章,为2表示草稿文章,为64表示为私有文章等等)。或者,请求参数可以通过status=enable、private等来访问“全部可见”文章、“仅我可见”文章。例如:https://bizapi.youkuaiyun.com/blog/phoenix/console/v1/article/list?status=enable&pageSize=20
  3. 浏览页面时,其获取文章列表的下一页数据的方式点击页码(Ajax请求,当前浏览页面的URL不会改变)。
作品数据-单篇文章分析页面

在作品数据页面,点击“单篇文章分析”后的页面(即https://mp.youkuaiyun.com/mp_blog/analysis/article/single),有获取文章列表数据的接口:https://bizapi.youkuaiyun.com/blog/phoenix/console/v1/data/single-article-list?page=1&size=20&type=date&action=down,其接口可以获取文章ID即articleId字段,文章地址即url字段,不过其postTime字段是文章初次发表时间而不是最后编辑时间,样例Response如下:

{
    "code": 200,
    "message": "success",
    "traceId": "f33d3ff4-d98e-4a79-9a4d-fe7e17105c11",
    "data": {
        "list": [
            {
                "title": "阿里云服务器 篇九:个人博客类网站",
                "createTime": "2024-11-02",
                "url": "https://blog.youkuaiyun.com/zhiyuan411/article/details/143426797",
                "viewCount": 29,
                "commentCount": 0,
                "favoriteCount": 0,
                "exposuresCount": 320,
                "fansCount": 0,
                "diggCount": 0,
                "articleId": 143426797,
                "type": 1,
                "postTime": "2024-11-02 14:00",
                "downloadUrl": "https://blog.youkuaiyun.com/phoenix/web/v1/share/download-share-data"
            },
            ...
        ],
        "total": 552,
        "page": 1,
        "size": 20
    }
}

注意:

  1. 该API在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
  2. 该页面有个“导出数据”按钮,可以一次性下载包含所有文章的Excel文件,但是,该Excel文件中不包含文章ID信息。具体来讲,除了文章标题,还包括创建日期、展现量、阅读量、评论数、收藏数、关注数。
  3. 浏览页面时,其获取文章列表的下一页数据的方式点击页码(Ajax请求,当前浏览页面的URL不会改变)。

获取最后编辑时间

在文章详情页面(即https://xiaobai.blog.youkuaiyun.com/article/details/143000743),没有发现有获取最后编辑时间的API,最后编辑时间是直接就已经渲染在HTML文件之内了,即主页面https://xiaobai.blog.youkuaiyun.com/article/details/143000743的返回内容中。

不过在该页面定义了在全局作用域中声明的JavaScript变量,可以直接使用JS代码如console.log(lastTime);来访问获取最后编辑时间的变量值。全局作用域中声明的JavaScript变量样例:

    <script>
    var articleId = 143000743;
    var privateData = [];
    var commentscount = 0;
    var commentAuth = 2;
    var curentUrl = "https://blog.youkuaiyun.com/zhiyuan411/article/details/143000743";
    var myUrl = "https://my.youkuaiyun.com/";
    var isGitCodeBlog = false;
    var highlight = ["数据", "信息", "相关", "股票", "资讯", "最新", "股市", "获取"];
    //高亮数组
    var isRecommendModule = true;
    var isBaiduPre = true;
    var baiduCount = 2;
    var setBaiduJsCount = 10;
    var viewCountFormat = 55;
    var share_card_url = "https://app-blog.youkuaiyun.com/share?article_id=143000743&username=zhiyuan411"
    var mallVipUrl = "https://mall.youkuaiyun.com/vip?vipSource=article"
    var vipArticleAbStyle = "t_2"

    var vipArticleCpStyle = "t_2"
    var articleType = 1;
    var baiduKey = "";
    var copyPopSwitch = true;
    var needInsertBaidu = true;
    var recommendRegularDomainArr = ["blog.youkuaiyun.com/.+/article/details/", "download.youkuaiyun.com/download/", "edu.youkuaiyun.com/course/detail/", "ask.youkuaiyun.com/questions/", "bbs.youkuaiyun.com/topics/", "www.youkuaiyun.com/gather_.+/"]
    var codeStyle = "tomorrow-night-eighties";
    var baiduSearchType = "";
    var sharData = "{\"hot\":[{\"id\":1,\"url\":\"https:\\/\\/img-blog.csdnimg.cn\\/a5f4260710904e538002a6ab337939b3.png\"},"..."],\"vip\":[{\"id\":1,\"vipUrl\":\"https:\\/\\/img-home.csdnimg.cn\\/images\\/20220920101150.png\",\"url\":\"https:\\/\\/img-home.csdnimg.cn\\/images\\/20220920101154.png\"},"..."],\"map\":{\"hot\":\"热门\",\"vip\":\"VIP\"}}";

    var canRead = true;
    var blogMoveHomeArticle = false;
    var showSearchText = "";
    var articleSource = 1;
    var articleReport = '{"pid": "blog", "spm":"1001.2101"}';
    var baiduSearchChannel = 'pc_relevant'
    var baiduSearchIdentification = '.235^v43^pc_blog_bottom_relevance_base7'
    var distRequestId = '1730649130732_14014'
    var initRewardObject = {
        giver: "zhiyuan411",
        anchor: "zhiyuan411",
        articleId: "143000743",
        sign: "6f2c7c7cb72e21e9ab692a7be55ea7c3",
    }
    var isLikeStatus = false;
    var isUnLikeStatus = false;
    var studyLearnWord = "";
    var unUseCount = 0;
    var codeMaxSize = 0;
    var overCost = true;
    var isCurrentUserVip = false;
    var contentViewsHeight = 0;
    var contentViewsCount = 0;
    var contentViewsCountLimit = 5;
    var isShowConcision = true
    var lastTime = "2024-11-02 14:43:01"
    var postTime = "2024-10-17 18:18:38"
    var isCookieConcision = false
    var isHasDirectoryModel = false
    var isShowSideModel = false
    var isShowDirectoryModel = true
    function getCookieConcision(sName) {
        var allCookie = document.cookie.split("; ");
        for (var i = 0; i < allCookie.length; i++) {
            var aCrumb = allCookie[i].split("=");
            if (sName == aCrumb[0])
                return aCrumb[1];
        }
        return null;
    }
    if (getCookieConcision('blog_details_concision') && getCookieConcision('blog_details_concision') == 0) {
        isCookieConcision = true
        isShowSideModel = true
        isShowDirectoryModel = false
    }
    </script>

加更:优快云登录

因为在不同平台无头浏览器框架技术会使用对应平台但相同版本的Chrome,例如:mac-121.0.6167.85和linux-121.0.6167.85,故造成不同平台之间录制的登录信息不能迁移复用。故,需要研究下优快云登录相关的部分,以使用无头浏览器框架技术直接支持登录相关操作。

登录状态判断

访问登录页面 https://passport.youkuaiyun.com/login,未登录状态会停留在该页面,已登录状态会跳转到首页(https://www.youkuaiyun.com/),故可以在短暂等待之后,判断是否包含登录页面元素即可判断出实际的登录状态。

具体选择的元素,可以使用下文模拟登录依赖的第一个元素即可。

模拟登录依赖的页面元素分析
“密码登录”Tab
// 元素
<span data-v-589acdb2="" class="">密码登录</span>

// 父级元素
<div data-v-589acdb2="" class="login-box-tabs-items"><span data-v-589acdb2="" id="last-login" class="last-login-way" style="display: none;">上次登录</span> <!----> <span data-v-589acdb2="" class="tabs-active">微信登录</span> <!----> <span data-v-589acdb2="" class="">免密登录</span> <span data-v-589acdb2="" class="">密码登录</span></div>

// selector
body > div.passport-container > div > div.passport-main.new-height > div.login-box > div.login-box-top > div.login-box-tabs > div.login-box-tabs-items > span:nth-child(4)

// XPATH(完整XPATH)
/html/body/div[2]/div/div[2]/div[2]/div[2]/div[1]/div[1]/span[4]
“手机号/邮箱/用户名”输入框
// 元素
<input data-v-0c30048a="" autocomplete="username" placeholder="手机号/邮箱/用户名" type="text" class="base-input-text">

// 父级和其父级元素
<div data-v-0c30048a="" data-v-676cd9d4="" class="base-input"><input data-v-0c30048a="" autocomplete="username" placeholder="手机号/邮箱/用户名" type="text" class="base-input-text"> <span data-v-0c30048a="" class="base-input-icon base-input-icon-clear" style="display: none;"></span> <!----> <!----></div>
<div data-v-676cd9d4="" class="login-form-item"><div data-v-0c30048a="" data-v-676cd9d4="" class="base-input"><input data-v-0c30048a="" autocomplete="username" placeholder="手机号/邮箱/用户名" type="text" class="base-input-text"> <span data-v-0c30048a="" class="base-input-icon base-input-icon-clear" style="display: none;"></span> <!----> <!----></div></div>

// selector
body > div.passport-container > div > div.passport-main.new-height > div.login-box > div.login-box-top > div > div.login-box-tabs-main > div.login-form > div:nth-child(1) > div > input

// XPATH(完整XPATH)
/html/body/div[2]/div/div[2]/div[2]/div[2]/div/div[2]/div[1]/div[1]/div/input
“密码”输入框
// 元素
<input data-v-0c30048a="" autocomplete="current-password" placeholder="密码" type="password" class="base-input-text" style="width: calc(100% - 16px);">

// 父级和其父级元素
<div data-v-0c30048a="" data-v-676cd9d4="" class="base-input"><!----> <input data-v-0c30048a="" autocomplete="current-password" placeholder="密码" type="password" class="base-input-text" style="width: calc(100% - 16px);"> <!----> <span data-v-0c30048a="" class="base-input-icon base-input-icon-password"></span> <!----></div>
<div data-v-676cd9d4="" class="login-form-item"><div data-v-0c30048a="" data-v-676cd9d4="" class="base-input"><!----> <input data-v-0c30048a="" autocomplete="current-password" placeholder="密码" type="password" class="base-input-text" style="width: calc(100% - 16px);"> <!----> <span data-v-0c30048a="" class="base-input-icon base-input-icon-password"></span> <!----></div></div>

// selector
body > div.passport-container > div > div.passport-main.new-height > div.login-box > div.login-box-top > div > div.login-box-tabs-main > div.login-form > div:nth-child(2) > div > input

// XPATH(完整XPATH)
/html/body/div[2]/div/div[2]/div[2]/div[2]/div/div[2]/div[1]/div[2]/div/input
“同意协议”勾选框
// 元素
<div data-v-589acdb2="" class="inform-title"><i data-v-589acdb2="" class="icon icon-nocheck"></i>我已阅读并同意
              <a data-v-589acdb2="" target="_blank" href="https://passport.youkuaiyun.com/service">服务条款</a><a data-v-589acdb2="" target="_blank" href="https://marketing.youkuaiyun.com/p/e1341fa2aa98ca4ded7455c654c74a62">隐私协议</a></div>
              
// 父级元素
<div data-v-589acdb2="" class="login-inform"><div data-v-589acdb2="" class="inform-title"><i data-v-589acdb2="" class="icon icon-nocheck"></i>我已阅读并同意
              <a data-v-589acdb2="" target="_blank" href="https://passport.youkuaiyun.com/service">服务条款</a><a data-v-589acdb2="" target="_blank" href="https://marketing.youkuaiyun.com/p/e1341fa2aa98ca4ded7455c654c74a62">隐私协议</a></div></div>

// selector
body > div.passport-container > div > div.passport-main.new-height > div.login-box > div.login-box-top > div > div.login-box-tabs-main > div.login-inform > div

// XPATH(完整XPATH)
/html/body/div[2]/div/div[2]/div[2]/div[2]/div/div[2]/div[2]/div
“登录”按钮
// 元素
<button data-v-23f9b684="" data-v-676cd9d4="" disabled="disabled" class="base-button">登录</button>

// 父级元素
<div data-v-676cd9d4="" class="login-form-item"><button data-v-23f9b684="" data-v-676cd9d4="" disabled="disabled" class="base-button">登录</button></div>

// selector
body > div.passport-container > div > div.passport-main.new-height > div.login-box > div.login-box-top > div > div.login-box-tabs-main > div.login-form > div:nth-child(4) > button

// XPATH(完整XPATH)
/html/body/div[2]/div/div[2]/div[2]/div[2]/div/div[2]/div[1]/div[4]/button

核心逻辑实现代码 index.js

import puppeteer from "puppeteer";
import moment from 'moment';
import fs from 'fs/promises';
import path from "path";

////// 入口主流程 开始 ///////

// 全局常量
// 需要备份的优快云的账号
const 优快云_USER_ID = 'zhiyuan411';
// 需要备份的优快云的账号密码
const 优快云_USER_PWD = 'your-password';
// 优快云登录信息的本地存储目录
const USER_DATA_DIR = './userData';
// 下载的优快云文章的本地存储目录
const DOWNLOAD_PATH = './downloads';
// 在文章列表页模拟向下翻页操作等场景的超时时间
const TIMEOUT = 10 * 60 * 1000;

console.log("开始执行优快云导出任务!");

// 获取命令行参数
const args = process.argv.slice(2);

// 定义默认值
const defaultRunMode = 'run';
const defaultDayOffset = -1;

// 解析参数
let runMode = args[0] || defaultRunMode;
let dayOffset = args[1] ? parseInt(args[1], 10) : defaultDayOffset;

// 参数校验
const validRunModes = ['run', 'debug', 'setup', 'login'];

if (!validRunModes.includes(runMode)) {
    console.error(`
  无效的 runMode: ${runMode}。
  有效值为:
    - 'run'(默认值):正常模式,启动无头浏览器,可以在没有图形界面的 CentOS 服务器环境中执行。
    - 'debug':调试模式,启动浏览器UI界面,可以观察浏览器运行情况并进行验证。
    - 'setup':设置模式,启动浏览器UI界面,用于记录登录信息。
    - 'login':模拟登录模式,启动浏览器UI界面,使用用户名、密码进行模拟登录优快云操作。
  `);
    process.exit(1);
}

if (isNaN(dayOffset) || !Number.isInteger(dayOffset)) {
    console.error(`
  无效的 dayOffset: ${args[1]}。
  请输入一个有效的整数。
  dayOffset 表示从今天0点往前多少天开始计算:
    - 0:从今天0点开始到现在。
    - 1:从昨天0点开始到现在。
    - 2:从前天0点开始到现在。
    - -1:(默认值)不限制开始日期,获取所有的全量文章。
  `);
    process.exit(1);
}

// 打印参数信息
console.log(`参数解析成功,runMode: ${runMode}, dayOffset: ${dayOffset}`);

// 等待指定的时间(毫秒)
const sleep = async (ms) => {
    await new Promise(resolve => setTimeout(resolve, ms));
};

// 立即执行的异步函数
(async () => {

    // 处理 setup 模式
    if (runMode === 'setup') {
        await setup();
        process.exit(0); // 正常退出,不再执行后续代码
    }

    // 初始化浏览器
    const {browser, page} = await initBrowser(runMode === 'run');

    // 模拟登录操作
    await login(page);

    // 处理 login 模式
    if (runMode === 'login') {
        // 仅完成登录,不再执行后续代码
        await sleep(2 * 60 * 1000);
        console.log('登录模式,仅完成登录操作,不再执行后续代码。')
        await browser.close();
        process.exit(0);
    }

    // 获取文章ID列表
    let articleInfos = await getArticleInfoArray(page, 优快云_USER_ID);

    // let articleInfos = [{
    //     articleId: 143426797,
    //     url: 'https://xiaobai.blog.youkuaiyun.com/article/details/143426797',
    //     editUrl: 'https://editor.youkuaiyun.com/md?articleId=143426797'
    // }, {
    //     articleId: 7108836,
    //     url: 'https://xiaobai.blog.youkuaiyun.com/article/details/7108836',
    //     editUrl: 'https://mp.youkuaiyun.com/mp_blog/creation/editor/7108836'
    // }]
    // dayOffset = 100;

    // 获取最后编辑时间,并过滤文章ID列表
    if (dayOffset >= 0) {
        articleInfos = await filterArticlesByLastTime(page, dayOffset, articleInfos);
    }

    // 下载优快云文章内容
    await downloadArticles(page, articleInfos);

    // 关闭浏览器
    if (runMode === 'debug') {
        // 调试时,等待30分钟,完成验证等操作
        console.log("已处理完所有任务,因为当前为调试模式,暂不退出,等待人工检查。")
        await sleep(30 * 60 * 1000);
    }
    await browser.close();

    // 打印任务结束信息
    console.log("优快云导出任务结束!");

})().catch((error) => {
    console.error("优快云导出任务发生错误:", error);
});


////// 入口主流程 结束 ///////


/**
 * 删除文件夹及其内容
 * @param {string} dirPath - 要删除的文件夹路径
 */
async function deleteFolderRecursive(dirPath) {
    if (await fs.stat(dirPath).catch(() => false)) {
        const files = await fs.readdir(dirPath);
        for (const file of files) {
            const filePath = path.join(dirPath, file);
            const stats = await fs.stat(filePath);
            if (stats.isDirectory()) {
                await deleteFolderRecursive(filePath);
            } else {
                await fs.unlink(filePath);
            }
        }
        await fs.rmdir(dirPath);
    }
}

/**
 * 设置模式,启动浏览器UI界面,用于记录登录信息。
 */
async function setup() {
    console.log('设置模式,启动浏览器UI界面,用于记录登录信息。');

    const LOGIN_URL = 'https://passport.youkuaiyun.com/login';

    try {
        await deleteFolderRecursive(USER_DATA_DIR);
        console.log(`userData 目录 ${USER_DATA_DIR} 已删除`);
    } catch (err) {
        console.error('删除 userData 目录时发生错误:', err);
    }

    // 创建下载目录,如果不存在则创建
    await fs.mkdir(DOWNLOAD_PATH, {recursive: true});
    console.log(`下载目录 ${DOWNLOAD_PATH} 已创建`);

    // 初始化浏览器,关闭无头模式
    const {browser, page} = await initBrowser(false);

    // 打开优快云登录页面
    await page.goto(LOGIN_URL);

    // 等待2分钟后再关闭浏览器,用于进行登录
    await sleep(2 * 60 * 1000);
    await browser.close();
}

/**
 * 模拟登录模式,启动浏览器UI界面,使用用户名、密码进行模拟登录优快云操作。
 * @param {import('puppeteer').Page} page - 页面对象
 */
async function login(page) {
    const LOGIN_URL = 'https://passport.youkuaiyun.com/login';

    // 打开优快云登录页面,并等待10s,以等待可能的登陆后跳转
    await page.goto(LOGIN_URL, {timeout: 30000, waitUntil: 'domcontentloaded'});
    await sleep(10 * 1000);

    // 使用XPath来查找“密码登录”Tab
    const passwordLoginTab = await page.$x('//span[text()="密码登录"]');

    if (passwordLoginTab.length === 0) {
        console.log('经验证,用户已登录');
        return; // 如果“密码登录”Tab不存在,认为用户已登录,退出函数
    }

    console.log('用户未登录,尝试登录...');

    // 点击“密码登录”Tab
    await passwordLoginTab[0].click();

    // 在“手机号/邮箱/用户名”输入框内输入用户ID
    const usernameInput = await page.$x('//input[@placeholder="手机号/邮箱/用户名"]');
    if (usernameInput.length > 0) {
        await usernameInput[0].type(优快云_USER_ID);
        await sleep(2000);
    } else {
        throw new Error('尝试登录失败:找不到“手机号/邮箱/用户名”输入框');
    }

    // 在“密码”输入框输入密码
    const passwordInput = await page.$x('//input[@placeholder="密码"]');
    if (passwordInput.length > 0) {
        await passwordInput[0].type(优快云_USER_PWD);
        await sleep(2000);
    } else {
        throw new Error('尝试登录失败:找不到“密码”输入框');
    }

    // 勾选“同意协议”勾选框
    const agreeCheckbox = await page.$x('//i[contains(@class, "icon-nocheck")]');
    if (agreeCheckbox.length > 0) {
        await agreeCheckbox[0].click();
        await sleep(2000);
    } else {
        throw new Error('尝试登录失败:找不到“同意协议”勾选框');
    }

    // 点击“登录”按钮
    const loginButton = await page.$x('//button[text()="登录"]');
    if (loginButton.length > 0) {
        await loginButton[0].click();
    } else {
        throw new Error('尝试登录失败:找不到“登录”按钮');
    }

    // 等待一段时间,确保页面加载完成
    await sleep(10 * 1000);

    // 再次检查“密码登录”Tab是否存在
    const passwordLoginTabAfterLogin = await page.$x('//span[text()="密码登录"]');

    if (passwordLoginTabAfterLogin.length > 0) {
        throw new Error('尝试登录失败:模拟登录后,仍然可以看到“密码登录”Tab');
    }

    console.log('登录成功!');

}


/**
 * 初始化浏览器
 * @param {boolean} [headless=true] - 是否开启无头模式
 * @returns {Promise<{browser: import('puppeteer').Browser, page: import('puppeteer').Page}>}
 */
async function initBrowser(headless = true) {
    console.log('初始化浏览器。');

    try {
        let browser;
        // 根据传入的参数决定是否开启无头模式,并且如果是无头模式则使用新的Headless实现
        const headlessOption = headless ? "new" : false;

        // userDataDir 表示把登录信息放到当前目录下,省着我们每次调用脚本都需要登录
        browser = await puppeteer.launch({
            headless: headlessOption, // 根据传入的参数决定是否开启无头模式
            userDataDir: USER_DATA_DIR,
            // args: [
            //     '--no-sandbox',  // 禁用沙盒
            //     '--disable-setuid-sandbox',  // 禁用 setuid 沙盒
            //     '--disable-dev-shm-usage',  // 禁用 /dev/shm 以防止内存不足的问题
            //     '--disable-gpu',  // 禁用 GPU 加速
            //     '--remote-debugging-port=9222'  // 可选:启用远程调试端口
            // ]
        });

        const page = await browser.newPage();
        await page.setViewport({width: 1080, height: 600});
        const client = await page.createCDPSession();
        // 允许下载,并设置下载目录
        await client.send("Page.setDownloadBehavior", {
            behavior: "allow",
            downloadPath: DOWNLOAD_PATH,
        });
        // 等待并自动关闭弹窗
        // 注册一个事件处理器,当页面上出现任何弹窗(如 alert, confirm, prompt 等)时,
        // 该处理器会被触发,并自动调用 dialog.accept() 方法来接受(关闭)弹窗。
        page.on("dialog", async (dialog) => {
            await dialog.accept();
        });
        return {browser, page};
    } catch (e) {
        console.error(e);
        throw e; // 抛出错误,这样调用者可以通过 .catch 来捕获
    }
}


/**
 * 获取文章ID列表
 * @param {import('puppeteer').Page} page - 页面对象
 * @param {string} userId - 用户ID
 * @returns {Promise<Array<Object>>} 包含文章ID等信息的数组
 */
async function getArticleInfoArray(page, userId) {
    console.log('获取文章ID列表。');

    const ARTICLES_PAGE_URL = `https://blog.youkuaiyun.com/${userId}?type=blog`;

    const articleInfos = [];
    const TARGET_URL = 'https://blog.youkuaiyun.com/community/home-api/v1/get-business-list';
    let totalArticles = 0;

    // 监听网络请求
    page.on('response', async (response) => {
        const requestUrl = response.url();
        if (requestUrl.includes(TARGET_URL)) {
            const data = await response.json();
            if (data.code === 200 && data.data && data.data.list) {
                data.data.list.forEach((article) => {
                    // 检查 articleId 是否已经存在于 articleInfos 中
                    if (!articleInfos.some(info => info.articleId === article.articleId)) {
                        articleInfos.push({
                            articleId: article.articleId,
                            url: article.url,
                            editUrl: article.editUrl
                        });
                    }
                });
                totalArticles = data.data.total;
            }
        }
    });

    // 访问列表页面并等待HTML文档已加载(无需等待图片等资源加载),30秒超时
    await page.goto(ARTICLES_PAGE_URL, {timeout: 30000, waitUntil: 'domcontentloaded'});

    // 设置超时时间
    const startTime = Date.now();

    // 模拟向下滑动
    while (true) {
        if (Date.now() - startTime > TIMEOUT) {
            console.error(`加载文章超时,超过${TIMEOUT / 1000 / 60}分钟仍未加载完毕。`);
            process.exit(1); // 退出程序
        }

        await page.evaluate(() => {
            // 因为初始化时设置的浏览器高度较小,故此处可以加大滑动距离
            window.scrollBy(0, 4 * window.innerHeight);
        });
        await sleep(2000); // 等待2秒,让页面加载新内容(时间太快容易被屏蔽)

        if (articleInfos.length === totalArticles) {
            // 如果文章数量已经达到总数量,说明已经没有新的文章加载
            await sleep(2000)
            break;
        }

        console.log(`当前已获取到的文章数量:${articleInfos.length} / ${totalArticles}`)
    }

    // 打印获取到的文章信息
    console.log(`获取到的文章数量:${articleInfos.length}。获取到的文章列表信息:`, articleInfos);

    return articleInfos;
}

/**
 * 获取最后编辑时间,并过滤文章ID列表
 * @param {import('puppeteer').Page} page - 页面对象
 * @param {number} dayOffset - 天数偏移量
 * @param {Array<Object>} articleInfos - 文章信息数组
 * @returns {Promise<Array<Object>>} 过滤后的文章信息数组
 */
async function filterArticlesByLastTime(page, dayOffset, articleInfos) {
    if (!articleInfos || articleInfos.length === 0) return [];

    const startDate = moment().subtract(dayOffset, 'days').format('YYYY-MM-DD');
    console.log(`获取最后编辑时间,并过滤文章ID列表。起始日期: ${startDate}`);

    const filteredArticles = [];
    const totalArticles = articleInfos.length;

    for (const [index, article] of articleInfos.entries()) {
        try {
            // 访问详情页面并等待HTML文档已加载(无需等待图片等资源加载),30秒超时
            await page.goto(article.url, {timeout: 30000, waitUntil: 'domcontentloaded'});

            // 等待 postTime 变量出现
            await page.waitForFunction(
                () => window.postTime !== undefined,
                {timeout: 10000} // 最多等待10秒
            );

            const postTimeValue = await page.evaluate(() => window.postTime);
            if (!postTimeValue) {
                throw new Error('未能获取到postTime变量');
            }

            // 尝试获取 lastTime 变量
            const lastTimeValue = await page.evaluate(() => window.lastTime);

            // 如果 lastTime 不存在或为 0,则使用 postTime 代替
            const timeValue = lastTimeValue && lastTimeValue !== '0' ? lastTimeValue : postTimeValue;

            const timeDate = moment(timeValue, 'YYYY-MM-DD HH:mm:ss').format('YYYY-MM-DD');

            if (runMode === 'debug') {
                console.log(`当前文章的最后修改时间为:${timeDate} @ `, article)
            }

            if (timeDate > startDate) {
                filteredArticles.push(article);
            }
        } catch (error) {
            console.error(`处理文章 ${article.articleId} 时发生错误:`, error);
        }

        // 打印当前的处理进度
        console.log(`根据最后编辑时间过滤文章的处理进度: ${index + 1} / ${totalArticles}`);

        // 每次处理完一篇文章后,等待2秒
        await sleep(2000);
    }

    // 打印获取到的文章信息
    console.log(`过滤后的文章数量:${filteredArticles.length}。过滤后的文章列表信息:`, filteredArticles);

    return filteredArticles;
}


/**
 * 下载优快云文章内容
 * @param {import('puppeteer').Page} page - 页面对象
 * @param {Array<Object>} articleInfos - 文章信息数组
 * @returns {Promise<void>}
 */
async function downloadArticles(page, articleInfos) {
    console.log('下载优快云文章内容。');

    const COMPLETED_ARTICLES = [];
    const totalArticles = articleInfos.length;

    for (const [index, article] of articleInfos.entries()) {
        if (COMPLETED_ARTICLES.includes(article.articleId)) {
            console.log(`文章 ${article.articleId} 已经下载,跳过。`);
            continue;
        }

        try {
            console.log(`正在处理文章 ${article.articleId},URL: ${article.editUrl}`);

            // 监听获取原文数据的接口
            const responsePromise = page.waitForResponse(response => {
                const url = response.url();
                // 通过检查响应头中的 Content-Type 来预检请求(即 OPTIONS 请求)等非 JSON 响应
                const isJsonResponse = response.headers()['content-type']?.includes('application/json');
                return (
                    isJsonResponse &&
                    (url.includes('https://bizapi.youkuaiyun.com/blog-console-api/v3/editor/getArticle') ||
                        url.includes('https://bizapi.youkuaiyun.com/blog-console-api/v1/editor/getArticle'))
                );
            });

            // 访问页面
            await page.goto(article.editUrl, {timeout: 60000, waitUntil: 'domcontentloaded'});

            // 等待响应
            const response = await responsePromise;
            const responseBody = await response.json();

            if (responseBody.code !== 200) {
                console.error(`获取文章 ${article.articleId} 数据时发生错误: ${responseBody.msg}`);
                continue;
            }

            const {data} = responseBody;
            const content = data.markdowncontent || data.content;
            const title = data.title;

            if (!content) {
                console.error(`文章 ${article.articleId} 内容为空,跳过。`);
                continue;
            }

            // 保存内容到文件
            const filePath = path.join(DOWNLOAD_PATH, `${title}.md`);
            await fs.writeFile(filePath, content, 'utf-8');
            console.log(`文章 ${article.articleId} 下载成功,保存到 ${filePath}`);

            // 记录已完成的文章
            COMPLETED_ARTICLES.push(data.article_id);
        } catch (error) {
            console.error(`处理文章 ${article.articleId} 时发生错误:`, error.message);
        }

        // 打印当前的处理进度
        console.log(`处理进度: ${index + 1} / ${totalArticles}`);

        // 每次处理完一篇文章后,等待2秒
        await sleep(2000);
    }
}

Node.js项目的 package.json 配置

{
  "name": "csdn-blogs-export",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node index.js run 1",
    "test": "node index.js debug 1",
    "debug": "node index.js debug 1",
    "setup": "node index.js setup",
    "login": "node index.js login",
    "all": "node index.js run"
  },
  "author": "cnfaq.cn",
  "type": "module",
  "dependencies": {
    "puppeteer": "^21.11.0",
    "moment": "^2.29.4"
  }
}

使用步骤

本地(桌面系统)中准备初始化数据
  1. 在本地新建项目目录,例如:mkdir csdn-blogs-export-main,拷贝核心逻辑代码文件 index.js 和 Node.js项目的 package.json 配置文件。
  2. 需要先安装Node.js和NPM环境,例如在MacOS下可以执行 brew install node 命令来安装。brew工具的安装可以参考 Mac OS系统使用笔记#安装终端的软件包管理器
  3. npm最好切换为国内源,使用命令:npm config set registry https://registry.npmmirror.com。国内源的地址可以参考 国内开源软件镜像站点参考
  4. 在项目根目录执行 npm install 来安装所有依赖。
  5. 修改 index.js 文件中头部的“全局常量”,改为自己的优快云账号。
  6. 在项目根目录运行初始化设置:npm run setup,在打开的Chrome浏览器窗口中登录自己的优快云账号。
  7. 可选:可以以调试模式在本地运行,对函数、功能等进行验证和调试:npm test
将本地代码、依赖和登录信息数据打包上传到服务器
  1. 将本地代码进行打包,zip或者tar -czvf csdn-blogs-export-main.tar.gz csdn-blogs-export-main
  2. 将本地打包文件scp到服务器,例如:scp ./csdn-blogs-export-main.tar.gz ecs-user@<服务器IP>:~/
  3. 登录服务器,解压打包文件,例如:tar -zvxf csdn-blogs-export-main.tar.gz
  4. 修改 index.js 的头部的 DOWNLOAD_PATH 为博客网站的Markdown文件存储路径的对应目录,例如:const DOWNLOAD_PATH = '/var/www/docker/flatnotes/data';
在服务器上安装Node.js和NPM环境
  1. 更新系统包列表sudo yum update

  2. 安装 EPEL 仓库sudo yum install -y epel-release

  3. 安装 Node.js 和 NPMsudo yum install -y nodejs npm

  4. 验证安装:验证 Node.js 和 NPM 是否安装成功:node -v && npm -v

如果需要特定版本的 Node.js,参考以下步骤:

  1. 安装 NodeSource 仓库:选择你想要安装的 Node.js 版本。这里以最新的 LTS 版本为例:curl -sL https://rpm.nodesource.com/setup_18.x | sudo bash -

  2. 安装 Node.js 和 NPMsudo yum install -y nodejs

推荐将npm切换为国内源,使用命令:npm config set registry https://registry.npmmirror.com。国内源的地址可以参考 国内开源软件镜像站点参考

在服务器上重新安装Chrome驱动

puppeteer 依赖的Chrome驱动位于~/.cache/puppeteer目录,在服务器端需要重新安装,执行以下命令:

cd ~/csdn-blogs-export-main

# 注意,以下命令执行时间较长,要是不能保证一直保持终端连接,就先执行虚拟终端命令,以防意外断开终端,前功尽弃
# sudo yum install -y screen
# screen
# 涉及到Chrome驱动(终端)的下载,下载时间很慢,耐心等待
npm install
# 下载的同时可以打开一个新窗口查看下载进度,CentOS系统中下载完成后的大小量级是:
# 352M    /home/ecs-user/.cache/puppeteer/chrome
# 185M    /home/ecs-user/.cache/puppeteer/chrome-headless-shell
watch -n 60 'du -sh ~/.cache/puppeteer/*'
# 如果仍然有问题,可以直接修复安装chrome驱动
# npx puppeteer browsers install chrome
安装无头Chrome依赖的其他库
sudo yum install -y atk
sudo yum install -y at-spi2-atk
sudo yum install -y libxkbcommon
sudo yum install -y libXcomposite
sudo yum install -y libXdamage
sudo yum install -y libXrandr
sudo yum install -y mesa-libgbm
sudo yum install -y pango

puppeteer.launch 方法中添加有助于在无头环境(没有图形界面的 CentOS 服务器环境)中运行的参数,从而确保 Puppeteer 能够顺利启动无头 Chrome 浏览器,即将原 index.js 文件的 initBrowser() 函数改为如下:

/**
 * 初始化浏览器
 * @param {boolean} [headless=true] - 是否开启无头模式
 * @returns {Promise<{browser: import('puppeteer').Browser, page: import('puppeteer').Page}>}
 */
async function initBrowser(headless = true) {
    console.log('初始化浏览器。');

    try {
        let browser;
        // 根据传入的参数决定是否开启无头模式,并且如果是无头模式则使用新的Headless实现
        const headlessOption = headless ? "new" : false;

        // userDataDir 表示把登录信息放到当前目录下,省着我们每次调用脚本都需要登录
        browser = await puppeteer.launch({
            headless: headlessOption, // 根据传入的参数决定是否开启无头模式
            userDataDir: USER_DATA_DIR,
            args: [
                '--no-sandbox',  // 禁用沙盒
                '--disable-setuid-sandbox',  // 禁用 setuid 沙盒
                '--disable-dev-shm-usage',  // 禁用 /dev/shm 以防止内存不足的问题
                '--disable-gpu',  // 禁用 GPU 加速
                '--remote-debugging-port=9222'  // 可选:启用远程调试端口
            ]
        });

        const page = await browser.newPage();
        await page.setViewport({width: 1080, height: 600});
        const client = await page.createCDPSession();
        // 允许下载,并设置下载目录
        await client.send("Page.setDownloadBehavior", {
            behavior: "allow",
            downloadPath: DOWNLOAD_PATH,
        });
        // 等待并自动关闭弹窗
        // 注册一个事件处理器,当页面上出现任何弹窗(如 alert, confirm, prompt 等)时,
        // 该处理器会被触发,并自动调用 dialog.accept() 方法来接受(关闭)弹窗。
        page.on("dialog", async (dialog) => {
            await dialog.accept();
        });
        return {browser, page};
    } catch (e) {
        console.error(e);
        throw e; // 抛出错误,这样调用者可以通过 .catch 来捕获
    }
}
验证和调试运行

注意:终端下不能使用debug模式、关闭无头模式:

cd ~/csdn-blogs-export-main

# 通过观察日志验证程序是否正确执行
npm start

# 验证其他间隔日期
node index.js run 10
在服务器上配置自动备份的定时任务

创建日志文件:

sudo touch /var/log/backup优快云.log
sudo chown ecs-user:ecs-user /var/log/backup优快云.log

执行 crontab -e,添加定时任务:

# 每天凌晨5点40分随机等待0-10分钟后开始备份优快云文章
40 5 * * * sleep $(python -c "import random; print(random.randint(1, 600))") && cd /home/ecs-user/csdn-blogs-export-main && npm start >> /var/log/backup优快云.log
全量备份之前的优快云文章
cd ~/csdn-blogs-export-main

# 文章数量较多时执行时间较长,可以考虑在虚拟终端(screen命令)中执行
# screen 
npm run all
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

李小白杂货铺

打赏是一种友谊,让我们更亲密。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值