文章目录
样例网站
系列文章
阿里云服务器 篇一:申请和初始化
阿里云服务器 篇二:搭建静态网站
阿里云服务器 篇三:提交搜索引擎收录
阿里云服务器 篇四: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"
}
}
注意:
- 富文本格式使用的是v1版本的API,经对比,其Response结构和v3版本是完全一致的。
- 2个版本的API都在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
其他方案
其他比较常见的方案都是直接访问文章地址后对获取的HTML内容进行处理,比如html2md
、html2txt
、html2pdf
等是有现成的开源转换项目的。
获取文章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
}
}
注意:
- 该API接口直接使用URL访问时会被提示“很抱歉,您访问的网站已开启安全防御,请完成 “安全验证” 后继续访问”,需要手工拖动滑块完成拼图后才能继续访问,且一段时间内不需要再次拖动滑块完成拼图就可以直接访问。其验证方式应该仍然是在请求header内采用了某些验证字段。
- 其请求参数
size
可以指定获取的文章数量,服务器限制的上限大概是在200条。 - 浏览页面时,其获取文章列表的下一页数据的方式是向下滑动页面(没有显式的页码)。
内容管理页面
在内容管理页面(即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"
}
}
}
注意:
- 该API在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
- 默认的结果是包含草稿状态的文章,可以使用Response中的
status
字段来筛选(为1
表示已公开发表文章,为2
表示草稿文章,为64
表示为私有文章等等)。或者,请求参数可以通过status=enable、private等来访问“全部可见”文章、“仅我可见”文章。例如:https://bizapi.youkuaiyun.com/blog/phoenix/console/v1/article/list?status=enable&pageSize=20 - 浏览页面时,其获取文章列表的下一页数据的方式点击页码(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
}
}
注意:
- 该API在请求header中有X-Ca-Key等验证字段,即使是在携带登录状态的cookies情况下,也不可以直接使用URL地址去直接请求。
- 该页面有个“导出数据”按钮,可以一次性下载包含所有文章的Excel文件,但是,该Excel文件中不包含文章ID信息。具体来讲,除了文章标题,还包括创建日期、展现量、阅读量、评论数、收藏数、关注数。
- 浏览页面时,其获取文章列表的下一页数据的方式点击页码(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"
}
}
使用步骤
本地(桌面系统)中准备初始化数据
- 在本地新建项目目录,例如:
mkdir csdn-blogs-export-main
,拷贝核心逻辑代码文件 index.js 和 Node.js项目的 package.json 配置文件。 - 需要先安装Node.js和NPM环境,例如在MacOS下可以执行
brew install node
命令来安装。brew工具的安装可以参考 Mac OS系统使用笔记#安装终端的软件包管理器。 - npm最好切换为国内源,使用命令:
npm config set registry https://registry.npmmirror.com
。国内源的地址可以参考 国内开源软件镜像站点参考。 - 在项目根目录执行
npm install
来安装所有依赖。 - 修改 index.js 文件中头部的“全局常量”,改为自己的优快云账号。
- 在项目根目录运行初始化设置:
npm run setup
,在打开的Chrome浏览器窗口中登录自己的优快云账号。 - 可选:可以以调试模式在本地运行,对函数、功能等进行验证和调试:
npm test
。
将本地代码、依赖和登录信息数据打包上传到服务器
- 将本地代码进行打包,zip或者
tar -czvf csdn-blogs-export-main.tar.gz csdn-blogs-export-main
。 - 将本地打包文件scp到服务器,例如:
scp ./csdn-blogs-export-main.tar.gz ecs-user@<服务器IP>:~/
。 - 登录服务器,解压打包文件,例如:
tar -zvxf csdn-blogs-export-main.tar.gz
。 - 修改 index.js 的头部的
DOWNLOAD_PATH
为博客网站的Markdown文件存储路径的对应目录,例如:const DOWNLOAD_PATH = '/var/www/docker/flatnotes/data';
。
在服务器上安装Node.js和NPM环境
-
更新系统包列表:
sudo yum update
-
安装 EPEL 仓库:
sudo yum install -y epel-release
-
安装 Node.js 和 NPM:
sudo yum install -y nodejs npm
-
验证安装:验证 Node.js 和 NPM 是否安装成功:
node -v && npm -v
如果需要特定版本的 Node.js,参考以下步骤:
-
安装 NodeSource 仓库:选择你想要安装的 Node.js 版本。这里以最新的 LTS 版本为例:
curl -sL https://rpm.nodesource.com/setup_18.x | sudo bash -
-
安装 Node.js 和 NPM:
sudo 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