这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战。
前言
你是否想了解github action基本功能?你是否想知道如何发送邮件通知?你是否想要拥有定时发布掘金文章的功能?我们一起看如何实现吧!
只需要10分钟,看完本文你可以收获
-
如何使用github action做定时任务
-
了解掘金文章定时发布的详细实现
-
如何使用脚本发送邮件通知
1. 曾经我们都有美好的愿望
1.1 你我都曾经历过的场景!
夜深了,您是否还在抓紧撰文,久久不愿入梦?
三巡已过,好文终成,发布按钮轻轻一点,“明天醒来千赞将属于我”,嘿嘿😋,我痴痴地想着。
一天过去了,昨晚苦熬的深度好文,只等来寥寥浏览量,零星三五赞,太难受了,亲爱的掘友们,我这么好的文章,为什么不给我赞啊?
是你文章写的不好吗?屁,绝对不是,是你发文的时间不对。
1.2 浏览量为什么这么少?
-
深夜发文: 虽然我们都很能熬,但是这么晚恐怕真的只有寥寥几人看了。
-
上班时间发文: 大家都喜欢摸鱼,但终归是有些“偷偷摸摸”,看你文章的人自然少了很多。
-
周六周日或者节假日发文: 难得的休息玩耍时间,大家都还有女朋友要陪呢!
1.3 如何抓住早上流量高峰时段?
不知道大家有没有发现,早上8点 ~ 10点间发布的文章数据一般都会比较好,可能是因为广大掘友们这时都在通勤路上,酷爱学习的你,时刻都在刷掘金。
有了更多的曝光机会,加上内容质量不错,符合一定人群的阅读学习需要,相信阅读量和赞一定不会差。
1.4 那么早,我起不来怎么办?
说那么多有啥用? 关键是我起不来啊!,昨天干到那么晚,今天怎么起的来?掘金又没有定时功能.
最后(很重要)
以上只是个人猜测,也是旁门左道,大家不要信,注意力还是要放在文章的内容和质量上O(∩_∩)O。
2. 先睹为快,定时发布
下面是写文章时,临时设定的时间做的测试,总共设计了三套模板,可以感知文章发布成功、失败和登录态失效。
2.1 发布成功模板
可以看到文章标题、简介、封面、点击后直接进去文章详情
2.2 发布失败模板
可以直接看到错误信息,方便排查问题
2.3 登录失效模板
登录失效后提醒
3. 只要三步,轻松用起来
如果你也想使用定时发布功能,和我一起来操作这三步,马上就有私人定制噢!
3.1 git clone 源码
git clone https://github.com/qianlongo/juejin-auto-publish-article-template.git
3.2 简单配置
定位到config配置文件,做一些简单的配置就差不多快要完成啦
// 配置信息
module.exports = {
headers: {
// 掘金浏览器 搜索cookie中的sessionid贴到这里
cookie: 'sessionid=xxxx',
},
email: {
// 一般填自己的qq邮箱就行
sendUserEmail: 'xxxx@qq.com', // 发送者邮箱
// 获取流程见这里 https://www.jeecms.com/xdjq/864.htm
sendUserPass: 'xxxx', // 发送者邮箱MTP协议密码
// 一般填自己的qq邮箱就行
toUserEmail: 'xxxx@qq.com', // 发送到谁,填邮箱
},
// 你的掘金用户信息
userInfo: {
// 头像
avatar: 'xxx',
// 昵称
nickName: 'xxx',
}
}
3.3 [AR]标注文章
例如晚上我正在写的这篇文章,只要再开头加上[AR]明早即可在8点多定时发布啦
[AR]掘金“定时发布文章"来了,只为努力创作的你。
4. 实现过程
4.1 大致思路
定时发布文章主要要解决几个主要问题,一个是
定时、一个是如何发布掘金文章、另一个是怎么通知结果
定时: 大家都知道github action功能,可以帮助我们在指定的时间(会有一定的颜色),执行脚本,那么就直接白嫖吧(或者也可以自己买个服务器,起一个定时任务)!
如何发布掘金文章: 模拟我们手动发布文章的过程即可,分析请求,模拟发送请求。
怎么通知结果: 使用nodemailer 很方便发送邮件,我们可以通过邮件来接收结果(一般微信都会绑定QQ邮箱通知)
4.2 请求分析
每个请求都会用到公共的headers如下
headers: {
origin: 'https://juejin.cn',
referer: 'https://juejin.cn/',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
'content-type': 'application/json',
// 主需要关注sessionid即可
cookie: 'sessionid=xxx',
},
4.2.1 获取草稿所有文章
首先我们发布的都是草稿箱内的文章,后续的所有操作都来源于此,主要参数已经标注了,有些直接看名字就知道什么意思。
假如有10篇草稿,但是我们只想明天发一篇、后天发一篇怎么办呢?
这个时候我们可以在标题上做手脚,例如:
- [AR]这是明天要发的
- [AR 2021-11-10] 这是指定的具体时间要发的文章
- 这是一篇无需自动发布的文章,因为还没有写好
所以借助[AR]的简单标识,我们可以很方便实现发布指定文章的功能
// 请求相关
url: https://api.juejin.cn/content_api/v1/article_draft/list_by_user
method: 'post'
data: { "keyword": "", "page_size": 10, page_no }
// 响应相关
{
"err_no": 0,
"err_msg": "success",
"data": [{
// 文章id
"id": "7028201579387830308",
"article_id": "0",
"user_id": "3438928099549352",
"category_id": "0",
"tag_ids": [],
"link_url": "",
"cover_image": "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9daf770b07f845a7bd2d9ebcd3753b65~tplv-k3u1fbpfcp-watermark.image?",
"is_gfw": 0,
// 文章标题
"title": "掘金“定时发布文章\"来了,只为努力创作的你。",
// 文章简介
"brief_content": "1. 你想要更多的流量,更多的赞吗? 1.1 你我都曾经历过的场景! 1.2 浏览量为什么这么少? 深夜发文: 虽然我们都很能熬,但是这么晚恐怕真的只有寥寥几人看了。 下午上班时间发文: 大家都喜欢",
"is_english": 0,
"is_original": 1,
"edit_type": 10,
"html_content": "",
"mark_content": "",
"ctime": "1636380800",
"mtime": "1636477399",
"status": 0,
"original_type": 0
}],
"count": 21
}
4.2 获取草稿文章详情
获取草稿文章详情主要是真正发出去的文章需要去掉刚才讲的
[AR]标识
// 请求相关
url: https://api.juejin.cn/content_api/v1/article_draft/detail
method: post
// 草稿的文章ID
data: { draft_id }
// 响应相关
{
"err_no": 0,
"err_msg": "success",
"data": {
"draft_id": "7028201579387830308",
// 只需要关注这个字段即可
"article_draft": {
"id": "7028201579387830308",
"article_id": "0",
"user_id": "3438928099549352",
// 需要注意的是,这个返回的是数组,其中每个成员都是数字类型,但是后面的更新文章,需要转化为字符串
"category_id": [],
"tag_ids": [],
"link_url": "",
"cover_image": "https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9daf770b07f845a7bd2d9ebcd3753b65~tplv-k3u1fbpfcp-watermark.image?",
"is_gfw": 0,
"title": "[AR]掘金“定时发布文章\"来了,只为努力创作的你。",
"brief_content": "",
"is_english": 0,
"is_original": 1,
"edit_type": 10,
"html_content": "deprecated",
"mark_content": "",
"ctime": "1636380800",
"mtime": "1636478151",
"status": 0,
"original_type": 0
},
"author_user_info": {
...
},
"category": {
...
},
"tags": [],
"user_interact": {
},
"columns": []
}
}
4.3 更新文章
借助获取草稿详情,我们可以将文章标题中[AR]去掉,还原最原始的文章标题
// 请求相关
url: https://api.juejin.cn/content_api/v1/article_draft/update
method: post
data: {
"id": "7028201579387830308",
"category_id": "6809637767543259144",
// 注意这里
"tag_ids": ["6809640407484334093", "6809640398105870343", "6809640369764958215"],
"link_url": "",
// 封面
"cover_image": "",
"is_gfw": 0,
"title": "掘金“定时发布文章\"来了,只为努力创作的你。 ",
// 简介
"brief_content": "xxx",
"is_english": 0,
"is_original": 1,
"edit_type": 10,
"html_content": "deprecated",
"mark_content": "xxxx"
}
// 响应相关
{
"err_no": 0,
"err_msg": "success",
"data": {
"id": "7028201579387830308",
"article_id": "0",
"user_id": "0",
"category_id": "6809637767543259144",
"tag_ids": [6809640407484334093, 6809640398105870343, 6809640369764958215],
"link_url": "",
"cover_image": "",
"is_gfw": 0,
"title": "掘金“定时发布文章\"来了,只为努力创作的你。 ",
"brief_content": "",
"is_english": 0,
"is_original": 1,
"edit_type": 10,
"html_content": "deprecated",
"mark_content": "",
"ctime": "-62135596800",
"mtime": "-62135596800",
"status": 0,
"original_type": 0
}
}
4.4 发布文章
终于到重头戏了,最重要的发布文章接口反而非常简单
// 请求相关
url: https://api.juejin.cn/content_api/v1/article/publish
method: post
data: {
// 文章ID,其他两个参数没有发现有什么用
draft_id: '',
sync_to_org: false,
column_ids: []
}
// 响应相关
发布完成页面即跳转走了
4.3源码实现
log.js
log.js主要为了缓存发布文章过程打印的信息,方便出错的时将结果直接反馈到邮件里,一眼看出问题所在
let catchLogs = []
module.exports = {
log (str) {
// 缓存
catchLogs.push(str)
console.log(str)
},
getLogs () {
// 读取
return catchLogs.join('\n')
},
clear () {
// 清除
catchLogs = []
}
}
sendEmail.js
发布邮箱通知
发送邮件所用
const nodemailer = require('nodemailer')
const emailTemplate = require('./emailTemplate')
const { email, titleMap, subTitleMap } = require('./config')
const {
sendUserEmail,
sendUserPass,
toUserEmail,
} = email
const sendMail = async (data) => {
const title = titleMap[data.templateType]
const subTitle = subTitleMap[data.templateType]
const html = emailTemplate(data)
let transporter = nodemailer.createTransport({
host: 'smtp.qq.com',
port: '465',
secureConnection: true,
auth: {
user: sendUserEmail,
pass: sendUserPass,
}
})
const sendEmailOptions = {
// 邮件标题
from: `"${title}" ${sendUserEmail}`,
// 发给谁
to: toUserEmail,
// 副标题: 主题
subject: subTitle,
// 邮件主要内容,见下文emailTemplate.js
html: html,
}
await transporter.sendMail(sendEmailOptions)
}
module.exports = sendMail
emailTemplate.js
发布文章结果的邮件模板,这里我大概设计了三套模板(实际只用了两套),实际UI见文首
const { userInfo } = require('./config')
module.exports = emailTemplate = (options) => {
const { articleInfos = [], ...others } = options || {}
const { avatar, nickName, introduce } = userInfo || {}
const { templateType = 'success', errorInfo= '' } = others || {}
const templateMap = {
success: `
<div class="user-info" style="display: flex; align-items: center; padding: 8px 20px;">
<img style="width: 60px; height: 60px; border-radius: 50%; margin-right: 10px;" class="avatar" src="${avatar}" alt="">
<div class="user-detail">
<div class="nick-name" style="font-size: 16px; font-weight: bold; line-height: 1.2; color: #000;">${nickName}</div>
<div class="desc" style="color: #30445a; font-size: 12px; padding-top: 6px;">${introduce}</div>
</div>
</div>
<div class="main">
${
articleInfos.map((articleInfo) => {
const { title, link, brief_content: briefContent, cover_image: coverImage } = articleInfo || []
return `
<div
class="content-wrapper"
style="
display: flex;
padding-bottom: 12px;
border-bottom: 1px solid #e5e6eb;
padding: 12px 20px;
"
>
<div class="content-main" style="flex: 1 1 auto">
<div class="title-row" style="display: flex; margin-bottom: 8px">
<a
href="${link}"
target="_blank"
rel=""
title="${title}"
class="title"
style="
font-weight: 700;
font-size: 16px;
line-height: 24px;
color: #1d2129;
width: 100%;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
text-decoration: none;
cursor: pointer;
"
>${title}</a
>
</div>
<div class="abstract" style="margin-bottom: 10px">
<a
href="${link}"
target="_blank"
rel=""
style="
color: #86909c;
font-size: 13px;
line-height: 22px;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
text-decoration: none;
-webkit-line-clamp: 2;
"
>
${briefContent}
</a>
</div>
</div>
<div
class="lazy thumb"
style="
flex: 0 0 auto;
width: 120px;
height: 80px;
margin-left: 24px;
background-color: #e0e0e0;
position: relative;
border-radius: 6px;
-o-object-fit: cover;
object-fit: cover;
background-image: url(${coverImage});
background-size: cover;
"
></div>
</div>
`
})
}
</div>
`,
error: `
<div style="max-width: 375px; padding: 12px 20px; display: flex; justify-content: center; align-items: center; flex-direction: column;">
<img style="width: 80%;" src="https://img13.360buyimg.com/ddimg/jfs/t1/158409/29/29190/14574/618a67a3E5125f564/b678db2f277c1d1b.jpg" alt="">
<div style="color: #86909c; font-size: 13px; font-weight: 500;">啊哦!定时发布出错了,快去github action看看吧</div>
<pre style="width: 100%; margin-top: 10px; padding: 0; overflow: scroll; color: #86909c;">
${ errorInfo }
</pre>
</div>
`,
notLogin: `
<div style="max-width: 375px; padding: 12px 20px; display: flex; justify-content: center; align-items: center; flex-direction: column;">
<img style="width: 80%;" src="https://img10.360buyimg.com/ddimg/jfs/t1/208608/4/8737/100853/618a6730Eee9841c1/f10752fd8efda184.png" alt="">
<div style="color: #86909c; font-size: 13px; font-weight: 500;">啊哦!登录过期啦, 快更新juejin cookie噢</div>
</div>
`,
}
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin: 0">
${templateMap[ templateType ]}
</body>
</html>
`
}
config.js
// 配置信息
module.exports = {
headers: {
origin: 'https://juejin.cn',
referer: 'https://juejin.cn/',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36',
'content-type': 'application/json',
// 主需要关注sessionid即可
cookie: 'sessionid=ce4fd61e80752ac58d3528f380d5714f',
},
email: {
sendUserEmail: '1162633511@qq.com', // 发送者邮箱
sendUserPass: 'cmkfvixcvrfdgahh', // 发送者邮箱MTP协议密码
toUserEmail: '1162633511@qq.com', // 发送到谁,填邮箱
},
// success error notLogin
// 根据你自己的喜好配置即可
// 不同状态下的标题
titleMap: {
success: '掘金定时发布提醒',
error: '掘金定时发布提醒',
notLogin: '掘金定时发布提醒'
},
// 不同状态下的副标题
subTitleMap: {
success: '恭喜!文章发布成功啦!',
error: '文章发布出错了!',
notLogin: '登录失效啦!'
},
// 你的掘金用户信息
userInfo: {
// 头像
avatar: 'https://p3-passport.byteacctimg.com/img/user-avatar/1957416d67153775f273064ea766a8d3~300x300.image',
// 昵称
nickName: '前端胖头鱼',
// 介绍
introduce: '今天又是努力的一天呢,加油加油噢!!!',
}
}
4.3.1 获取所有草稿文章,并过滤需要发布的文章
// 递归获取草稿箱内所有文章
const getAllDraftArticles = async (allDraftArticles = [], page_no = 1) => {
const result = await axios({
url: 'https://api.juejin.cn/content_api/v1/article_draft/list_by_user',
method: 'post',
headers,
data: { "keyword": "", "page_size": 10, page_no }
})
const draftArticles = result.data
const count = result.count
allDraftArticles = allDraftArticles.concat(draftArticles)
if (allDraftArticles.length === count) {
log(`第① 步:检测到草稿箱内有${allDraftArticles.length}篇文章`)
return allDraftArticles
} else {
return getAllDraftArticles(allDraftArticles, page_no + 1)
}
}
// 过滤即将发布的文章
const PUB_ARTICLE_RE = /[\【\[]AR(?: )?(\d{4}-\d{2}-\d{2})?[\]\】]/i
const filterPubArticle = (articles = []) => {
// 注意这里,github action得到的时间比北京时间早8小时
const today = dayjs(new Date()).add(8, 'h').format('YYYY-MM-DD HH:mm:ss')
log(`第② 步:开始筛选需今日(${today})发布的文章`)
const filterResult = articles.filter((article) => {
const matchResult = article.title.match(PUB_ARTICLE_RE)
const pubDate = matchResult && matchResult[1]
// 指定了日期的话,比较日期时间,年-月-日
if (pubDate) {
const isTodayPub = today === pubDate
log(`文章${article.title}, 指定了发布日期${pubDate}, 今天是${today}, ${isTodayPub ? '今日即将发布' : '未到发布日期'}`)
return isTodayPub
} else {
// 否则的话看是否符合[AR] [AR 2011-11-10]这种格式
return matchResult
}
}).map((it) => {
return {
...it,
title: it.title.replace(PUB_ARTICLE_RE, ''),
link: `https://juejin.cn/post/${it.id}`
}
})
log(`----筛选出${filterResult.length}篇已标志且今日即将发布的文章----`)
return filterResult
}
4.3.2 获取草稿内文章详情
// 获取草稿内文章详情
const getDraftDetail = async (draft_id) => {
const result = await axios({
url: 'https://api.juejin.cn/content_api/v1/article_draft/detail',
method: 'post',
data: {
draft_id
},
headers
})
return result.data
}
4.3.3 更新草稿文章
// 更新文章
const updateDraftDetails = async (draft_articles = []) => {
log('第③ 步:去除文章标题中定时发布标识')
return Promise.all(draft_articles.map(async (article) => {
const { article_draft } = await getDraftDetail(article.id)
const originTitle = article_draft.title.replace(PUB_ARTICLE_RE, '')
const result = await axios({
url: 'https://api.juejin.cn/content_api/v1/article_draft/update',
method: 'post',
headers,
data: {
...article_draft,
title: originTitle,
// 需要转换tag,因为详情的tag是number
tag_ids: article.tag_ids.map((tagId) => '' + tagId)
}
})
log(`${article.title}:已去除标识,可原文发布`)
return result
}))
}
4.3.4 发布文章
const publishArticles = (articles = []) => {
log('第④ 步:开始挨个发布文章')
return Promise.all(articles.map(async (article) => {
log(`发布文章: ${article.title} ${article.link}`)
const result = await axios({
url: 'https://api.juejin.cn/content_api/v1/article/publish',
method: 'post',
headers,
data: {
draft_id: article.id,
sync_to_org: false,
column_ids: []
}
})
log(`发布成功`)
return result
}))
}
4.3.5 整体入口
const init = async () => {
try {
// 1. 获取草稿箱内所有的文章
const draftArticles = await getAllDraftArticles()
// 2. 筛选需今日发布的文章
const filterPubArticles = filterPubArticle(draftArticles)
if (filterPubArticles.length) {
// 3. 更新筛选结果的文章标题,去除[AR 2021-11-08]、[AR]标识
await updateDraftDetails(filterPubArticles)
// 4. 开始发布文章
await publishArticles(filterPubArticles)
// log(filterPubArticleResult)
sendEmail({
templateType: 'success',
articleInfos: filterPubArticles
})
}
} catch (err) {
console.log('出错了', err)
try {
log(JSON.stringify(err))
} catch (e) {
console.log(e)
}
sendEmail({
templateType: 'error',
errorInfo: getLogs(),
})
clear()
}
}
init()
5 相约再见
夜深了,愿大家晚安 好梦