掘金“定时发布文章"来了,只为努力创作的你

2,614 阅读10分钟

这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战

前言

你是否想了解github action基本功能?你是否想知道如何发送邮件通知?你是否想要拥有定时发布掘金文章的功能?我们一起看如何实现吧!

只需要10分钟,看完本文你可以收获

  1. 如何使用github action做定时任务

  2. 了解掘金文章定时发布的详细实现

  3. 如何使用脚本发送邮件通知

1. 曾经我们都有美好的愿望

1.1 你我都曾经历过的场景!

夜深了,您是否还在抓紧撰文,久久不愿入梦?

三巡已过,好文终成,发布按钮轻轻一点,“明天醒来千赞将属于我”,嘿嘿😋,我痴痴地想着。

一天过去了,昨晚苦熬的深度好文,只等来寥寥浏览量,零星三五赞,太难受了,亲爱的掘友们,我这么好的文章,为什么不给我赞啊?

是你文章写的不好吗?屁,绝对不是,是你发文的时间不对

1.2 浏览量为什么这么少?

  1. 深夜发文: 虽然我们都很能熬,但是这么晚恐怕真的只有寥寥几人看了。

  2. 上班时间发文: 大家都喜欢摸鱼,但终归是有些“偷偷摸摸”,看你文章的人自然少了很多。

  3. 周六周日或者节假日发文: 难得的休息玩耍时间,大家都还有女朋友要陪呢!

1.3 如何抓住早上流量高峰时段?

不知道大家有没有发现,早上8点 ~ 10点间发布的文章数据一般都会比较好,可能是因为广大掘友们这时都在通勤路上,酷爱学习的你,时刻都在刷掘金。

有了更多的曝光机会,加上内容质量不错,符合一定人群的阅读学习需要,相信阅读量和赞一定不会差。

1.4 那么早,我起不来怎么办?

说那么多有啥用? 关键是我起不来啊!,昨天干到那么晚,今天怎么起的来?掘金又没有定时功能.

最后(很重要)

以上只是个人猜测,也是旁门左道,大家不要信,注意力还是要放在文章的内容和质量上O(∩_∩)O。

2. 先睹为快,定时发布

下面是写文章时,临时设定的时间做的测试,总共设计了三套模板,可以感知文章发布成功、失败和登录态失效。

2.1 发布成功模板

可以看到文章标题、简介、封面、点击后直接进去文章详情

image.png

2.2 发布失败模板

可以直接看到错误信息,方便排查问题

image.png

2.3 登录失效模板

登录失效后提醒

image.png

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篇草稿,但是我们只想明天发一篇、后天发一篇怎么办呢?

这个时候我们可以在标题上做手脚,例如:

  1. [AR]这是明天要发的
  2. [AR 2021-11-10] 这是指定的具体时间要发的文章
  3. 这是一篇无需自动发布的文章,因为还没有写好

所以借助[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 相约再见

夜深了,愿大家晚安 好梦