将本身在CSDN上的文章下载到本地并上传到掘金

CSDN 算是一个老牌技术网站了,不少喜欢写文章的人,一开始都是在 CSDN上发布,可是可能因为某些缘由,有的人想把本身在 CSDN上的文章放到其余的网站上(嗯,好比掘金),可是因为在 CSDN上发布的文章数量不少,一篇篇复制粘贴下来理论上是可行的,就是手酸了点。前端

不过,做为技术型体力劳动者人才,重复一种动做几十甚至上百遍未免有点丢失 biger,想起前段时间我花费了 大量时间 翻译的 Puppeteer,至今还没体现出其价值来,因而决定就用它了。git

本文的可运行示例代码已经上传到 github了,须要的请自取,顺手 star哦~github


下载

想要获取到文章的标题和内容信息,第一个想到的就是文章的详情页,标题就一行,没那么多道道还好说,可是内容就要复杂点了,若是直接分析内容元素的 DOM结构固然可行,但未免有点麻烦,若是直接获取内容的字符串,例如使用 textContent这种方法,又会丢失语义,没办法得到内容的层级结构数组

不过我转念一想,既然这文章是本身的,那么彻底能够进入文章的编辑页啊,编辑框内的内容不就是文章的原始内容吗,我写文章都是用 md编辑器,那么编辑框里的内容就是 md源文件,正是我想要的东西。浏览器

想要进入后台编辑页,必需要登陆,因而先登陆, CSDN的登陆页连接是 https://passport.csdn.net/account/login,登陆分为帐号密码登陆和第三方登陆,就按最简单的来,因此我选择了帐号密码登陆,若是你以前一直是第三方登陆,没有帐号密码,那么你能够选择如今去建立一个,或者换一种写法,就用第三方登陆,我这里演示帐号密码登陆的流程 第三方登陆你本身想办法

await page.goto('https://passport.csdn.net/account/login', { timeout })
// 切换到帐号密码登陆
await page.click('.login-code__open')
await page.waitForSelector('#username')
await sleep()
// 在表单中输入 帐号 密码
await page.type('#username', 'Your Name')
await page.type('#password', 'Your pwd')
// 登陆
const loginBtn = await page.$('.logging')
// 点击登陆按钮
await loginBtn.click()
await page.waitForNavigation()
console.log('csdn登陆成功')
复制代码

登陆成功后,先别急着去编辑页,由于还没肯定编辑页连接,先把你本身全部发布的文章详情页的连接拿到,就拿我本身来举栗吧。cookie

每一个人创做者都有这样一个页面,例如 blog.csdn.net/DeepLies,这个页面罗列了你全部的文章:网络

在这个页面的最底部,有个能够翻页的区域:app

从这里能够知道本身的文章共能够分为多少个翻页,同时也能够获得每一个翻页的地址连接,例如,点击下面的页码 2,就会去往 blog.csdn.net/DeepLies/ar…,这个连接前面部分是固定的,就叫它 base url 吧,只有最后那个数字会改变,这个数字就对应相应翻页的地址编辑器

// 前往文章列表页 第 1 页,从第1页获取全部的翻页信息
  await page.goto('https://blog.csdn.net/DeepLies/article/list/1', { timeout })
  // 获取最底部的翻页区域,获取全部翻页页面
  await page.waitForSelector('#pageBox li')
  sleep()
  // 获取全部页码 li内的文本,做用是根据这些文本中的数字找到最大页码
  const pageNumLiText = await page.$$eval('#pageBox li', eles => Array.from(eles).map(ele=>ele.textContent))
  // 从获取到的页码中计算出最大页码,最大页码也就等于翻页的总页面数
  const maxPageNum = Math.max.apply(Math, pageNumLiText.reduce((total, d) => {
    if (!isNaN(Number(d))) return total.concat(+d)
    return total
  }, []))
  await browserManage.closeCtrlPage(page)

  // 翻页的 共同 url字符串
  const baseUrl = 'https://blog.csdn.net/DeepLies/article/list/'
  // listPage 暂存了全部的翻页连接
  let listPage = []
  for (let i = 1; i < maxPageNum + 1; i++) {
    listPage.push(baseUrl + i)
  }
复制代码

因而,目前为止,咱们能够拿到全部的翻页,而后每一个翻页上有对应的文章列表,根据这个元素就能够获得文章的 articleId,每篇文章详情页和编辑页的 base url都是同样的,文章详情页(例如 https://blog.csdn.net/DeepLies/article/details/81511722)的)的 base urlhttps://blog.csdn.net/DeepLies/article/details,文章编辑页(例如 https://mp.csdn.net/mdeditor/81511722)的 base urlhttps://mp.csdn.net/mdeditor,可见,只要拿到文章的 id,就能获得其详情页和编辑页的地址,遍历全部的翻页,就能获得全部的文章的 articleIdpost

// 前往每个翻页
await evPage.goto(url, { timeout })
await evPage.waitForSelector('.article-item-box')
// 获取到当前翻页的全部文章的id,这里是经过截取文章详情页的地址字段获得的 articleId
const pageDetailIds = await evPage.$$eval('.article-item-box h4 a', eles => eles.map(e => e.href.match(/\/(\d+)$/)[1]))
复制代码

根据文章的 articleId获得文章的编辑页地址,就能进入编辑页直接从编辑框中得到文章 md正文,从标题框中得到文章的 标题完美结束

不过,还有个问题,文章正文中除了通常的文字以外,还有图片连接,按理说,这个图片连接是网络连接,直接引用是没问题的,butCSDN 不只 给图片加了水印,并且 还加了防盗链!

这是水印:

这是防盗链:

嗯,水印的事情我看了一圈,加上去容易可是弄下来就比较复杂了,能够搞可是不太好搞,不是本文主要探究的事情 至于防盗链就更狠了,我试了下,这不只仅是加个 Referer或者带个 Cookie就能解决的事情 不过这都不要紧,过程不重要,结果才是想要的,我不是要引用这个图片,我是要下载它,管它什么水印、防盗链,物理攻击一次性解决,绝招:截图大法

// 对图片进行截图操做
await pageHandle.screenshot({ path: path.resolve(__dirname, `../article/${title}/${fileName}`), clip })
复制代码

反正无论怎么说,这图片确定是能够被看到的,既然能够被看到,那就能够被截图,puppeteer 提供的 screenshot 真乃搞定防盗链图片的利器也,之后你们有搞不定的防盗链图片,就把它截下来,固然,因为是截图,因此若是是 gif格式的动图,那就没办法了,你手动滑稽下载吧

至于水印,仔细看了下,根据CSDN给图片加水印的方法,其实只要把图片连接最后面 ?后面全部的参数所有去掉,留下来的连接就是没有加水印的图片连接 例如正常文章中的图片连接是 https://img-blog.csdn.net/20171220155632166?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvRGVlcExpZXM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast,而这个图片的无水印版连接是 https://img-blog.csdn.net/20171220155632166,因此只须要将图片后面的参数所有去掉,显示在浏览器中的图片就是无水印版本的图片,而后再结合上面的截图,就 get

这里须要注意的是,原先文章内的防盗链连接是用不了的,你把图片下载到本地后,要把文章中对应的图片连接换成本地的(固然,是用代码换),不然你下载下来后文不对图有什么意义

这里有几个坑须要注意一下

  • 1号坑

若是你想使用文章本来的标题当作下载下来文章的文件名,那么有些含有特殊字符的标题要特殊对待一下,由于某些特殊字符,例如 /:*?"<>|,这些是不能做为文件名保存的,这不是代码的问题,是操做系统的问题,因此碰到这些字符就要额外处理一下:

const clearFilePath = filePath => {
  // 我这里把特殊字符统统换成 !
  return filePath.replace(/[\/:*?"<>|]/g, '!')
}
复制代码
  • 2号坑

使用 screenshot方法截图的时候,若是有的文章图片一半在屏幕视野内,一半在屏幕视野外,就像这样:

则对这种图片进行截屏的时候,可能会出现把滚动条截进去的状况,最后截图是这样的:

这其实很好理解,用眼睛来看的话,图片原本就是被滚动条挡住的,截图的时候滚动天然就要被一同截进来,不过下面一半眼睛是看不到,为何也能被截进来,甚至那些彻底在视野外的也都能完整的进行截取?可能这种实现就是所谓的一半从实际状况一半从需求方面考虑了吧。

先别管为何这么设计,但既然找到缘由,解决便可,这里我采用的解决手段也很简单,既然有滚动条,那我把滚动条隐藏不就好了:

document.body.style.overflow = 'hidden'
复制代码

直接给 body设个 overflow的样式,这样就没有滚动条了,并且前面也说了,屏幕外的图片也能够被截取,被屏幕 overflow:hidden的元素也能够截取,因此没必要担忧图片也这个样式给 hidden掉了, 外国的东西就是好,人性化

  • 3号坑

上一条提到,使用 screenshot这个方法截图,大部分状况下截图的结果,和你手动开浏览器截图是同样的,那么若是有什么东西(没错,说的就是广告)挡在了你所要截图的图片上面,那毫无疑问也会被截取下来,因此须要将广告屏蔽掉,最简单的方式就是 display: none

目前这个时间点,在我目前这个电脑上,只有一条广告影响到了截图,我就直接把它 display: none掉了,可是广告这种东西变化莫测,说不定哪天就换了一个位置换了一个形式,那就须要使用者自行寻找并 display: none之了。

上传

经过前面的步骤,就能够将 CSDN上的文章下载到本地了,不过我还想给弄到掘金上,由于我总以为前端的文章在掘金上曝光量更大,上传这一步我决定换个方法,上面使用 puppeteer用得太累了,想要调接口直接把文章传上去,然而,我发现掘金并不打算给我找个机会:想要使用上传接口不只仅须要带 cookie,还须要带 token 什么仇什么怨

cookie好解决,可是 token这个东西很差弄,我深深地思考了下,考虑到前面用 puppeteer差很少也熟手了,仍是继续用下去吧 puppeteer 真香

首先,仍是老规矩,想上传确定是要登陆的,掘金登陆也分帐号密码和第三方登陆两种,这里仍是用最简单的帐号密码登陆

await page.goto('https://juejin.im/', { timeout })
// 点击 .login元素,把登陆弹窗弄出来
await page.click('.login')
await page.waitForSelector('.auth-form')
await sleep()
// 输入帐号密码
await page.type('input[name=loginPhoneOrEmail]', 'Your Name')
await page.type('input[name=loginPassword]', 'Your pwd')
const loginBtn = await page.$('.auth-form .btn')
// 点击 登陆 按钮
await loginBtn.click()
await page.waitForNavigation()
console.log('掘金登陆成功')
复制代码

读取须要上传的文章,有两种方法,一种是等前面下载完了,再读取本地文件进行上传;另一种就是一边下载一边上传,直接利用下载时获取的文章信息便可,这样就少了读取本地文件这一步,这里采用后一种

这一步的难点在于,要从正文中获取到全部的图片连接(由于下载到本地了,因此这里就是图片的本地路径),并把图片连接对应的本地图片文件进行上传,而后再获取到对应上传后的网络路径,替换掉本地图片路径,就组成了一篇能够直接上传的正文

因为不清楚一篇文章中到底有多少的图片,也不清楚这些图片都放在什么地方,因此须要全文检索,这里采用的手段以下:

  • 将文章正文当作是一个长字符串,将正文中的每一个图片连接(例如 ![img](http://example.com/img.png))都当作是这个长字符串的分隔符,这样正文就被图片连接分割成了 n
  • 根据上一步,将图片连接和剩下的正文分开,分别存储到两个变量中,例如 var contentArr = [content1, content2, content3...]var imgArr = [pic1, pic2...],这里把图片连接当作是正文的分隔符,因此正文数组的长度确定是比图片数组长度大 1的。
    const contentArr = content.split(/!\[.*?\]\(.*?\)/)
    const imgArr = []
    content.replace(/!\[.*?\]\((.+?)\)/g, (m1, m2) => imgArr.push(m2))
    复制代码
  • 上传 imgArr中的数据项,这些数据项目前都是本地图片路径,上传后便可在掘金的编辑框内获得相应的网络路径,获得图片网络地址的数组 imgJuejinUrlArr
    const relativePath = `article/${articleTitle}/${fileBaseName}`
    const uploadInput = await page.$('.image-file-selector', { hidden: true })
    // 遮罩层出现,说明开始上传
    await uploadInput.uploadFile(relativePath)
    复制代码

  • 按照顺序依次拼接 contentArrimgJuejinUrlArr 内的数据项,获得上传正文
    const juejinContent = contentArr.reduce((t, c, i) => {
      return t.concat(c, imgJuejinUrlArr[i] || '')
    }, []).join('')
    复制代码
  • 上传文章

原理就是上面那样,代码可能稍微有些复杂,须要调试的时间可能会有点长

总结

当我准备用代码实现本文功能的时候,我以为或许有点复杂,一时半会应该不太能搞定,怎么也得两三天的时间吧 可是实在是没想到,棘手程度远超预期,利用周末和下班后那点时间,断断续续一直搞了一个多星期才最终搞定, 因为对 puppeteer不是很熟,掉了不少坑,又由于开始时没预料到这个功能那么复杂,因此对代码结构没什么规划, 直到写到一半才不得不把代码重构了一遍,另外,因为此功能时调用浏览器模拟对网站的实际操做, 还须要考虑到网速、超时、占用资源等实际因素,都要一一解决。 本意是想解放劳动力,优雅而快速地搞定实际需求,谁知写代码花费的时间彻底足以我手工人肉把我在 csdn上的文章下载下来并上传到掘金好几遍了!天杀的

另外,目前这个时间点,此项目可正常运行,可是爬虫(姑且认为我写的是一个爬虫吧)这个东西,特别是本文项目这种与网页元素结构紧密联系的爬虫,可能只是由于目标页面换了个元素结构、换了个类名、换了个展现逻辑、换了个接口……就不能用了,因此若是碰到这种问题,请自行解决一下,大致处理逻辑是不变的,修正这种小问题就很简单了。

相关文章
相关标签/搜索