由于XX竟然开启了防盗链,我连夜写个爬虫🕷️导出博客所有内容

前言

事情是这样的,在写完上一篇文章后,将 markdown文件部署到了自己的服务器上,然后发现自己博客的图片全部没有了,F12一看,才发现都变成了 403。这是为啥呢?

噢,原来是我之前,把掘金当图床在使,狠狠得薅掘金一两年的羊毛。结果不知道啥时候突然开启了防盗链。导致了出现这个P1级事故(破站虽然没人访问)。

因为我的文章都是靠图片占面积的,这么多图片一个个替换太麻烦了,所以我接下来打算将掘金上的文章全部爬下来包括图片,重新部署到自己的博客(vitepress)上去。

什么是防盗链

首先静态资源就是一个URI,防盗链就是为了防止别人拿你的链接,到处引用。比如我的个人博客,又或者其他平台的文章都在用掘金的图片,那么掘金的流量就会暴增。

所以一般开启防盗链是有必要的,当然我那个小破站,月 UV 数量,估摸着也就 5个左右。防人之心不可无,我还是先开启为敬了。

本次先不带来防盗链的实现,后续有机会补充吧。

实践Nodejs爬虫

在前面说到了,我需要将文章还有图片都下载下来,文章我转为 Markdown 存储。图片保持文件类型就行,直接下载到一个 images 目录内。

需要用到的插件

  • cheerio 类似 JQuery 一样的DOM操作(golang 有个 goquery)
  • turndown 将 HTML 转化成 Markdown

大致思路如下

实现一个简易爬虫

简单实现一个通过URL获取HTML的小功能,先请求拿到内容,然后通过cheerio插件选中到掘金存放文章的根元素,最后通过 turndown 插件转化成 Markdown。

js 复制代码
const request = require('request');
const cheerio = require('cheerio');
const fs = require('fs');
const TurndownService = require('turndown');
const turndownService = new TurndownService();
// 掘金文章链接
const url = 'https://juejin.cn/post/7221567098466615353';

request(url, (error, response, body) => {
  if (!error && response.statusCode === 200) {
    // 解析DOM元素
    const $ = cheerio.load(body);
    // 获取文章内容
    const content = $('.markdown-body').html();
    // 转换为markdown
    const markdown = turndownService.turndown(content);
    // 写入文件
    fs.writeFileSync('./docs/严老湿的文章.md', markdown);
  }
});

可以看到markdown文件已经出来了。

文件内容也都转换成功了,只是图片地址,我们还没有将图片进行保存和替换,接下来一起处理下。

下载文章内的图片

大家应该也猜到了,前面我们使用的 $ 用法,我们可以通过这个 find('img')这个API获取所有的img标签然后进行遍历,注意在这使用的 mapcheerio 提供的。

diff 复制代码
request(url, (error, response, body) => {
  if (!error && response.statusCode === 200) {
    // 解析DOM元素
    const $ = cheerio.load(body);
    // 获取文章内容
    const content = $('.markdown-body').html();
+   const imageElements = $('.markdown-body').find('img');

+   imageElements.map((index, img) => {
+     console.log(img.attribs.src);
+   })
    // 转换为markdown
    const markdown = turndownService.turndown(content);
    // 写入文件
    fs.writeFileSync('./docs/严老湿的文章.md', markdown);
  }
});

可以看到已经遍历出所有图片的链接了,接下来就只需要,将图片保存下来就行了。

保存图片

先通过request发起一次 head 这样可以拿到请求头的信息,获取扩展名。

Tips: head 请求只会返回响应头的信息。与其他 HTTP 请求有所不同。

使用path.basename方法从URL中提取文件名并继续通过request请求进行图片下载,然后通过 fs 模块进行写入流并保存到本地。

js 复制代码
const imageElements = $('.markdown-body').find('img');
// 遍历图片元素
imageElements.map((index, img) => {
  const imageUrl = img.attribs.src;
  request.head(imageUrl, (err, res, body) => {
    // 获取文件扩展名
    const contentType = res.headers['content-type'];
    const extname = contentType ? `.${contentType.split('/')[1]}` : '';
    // 获取文件名
    const filename = path.basename(imageUrl);
    // 创建写入流
    const stream = fs.createWriteStream(path.join(__dirname, 'docs/images', filename + extname));
    // 管道流
    request(imageUrl)
      .pipe(stream)
      .on('close', () => console.log('Image downloaded successfully.'));
  });
});

替换图片路径

现在我们已经将文章内的所有图片都保存到本地了,但是我们并未将Markdown里面的图片链接进行替换成本地路径,这样并没有解决我的实际问题,于是接下来将进行替换。

我的思路如下,在真正写入 Markdown 之前必须将所有图片替换下载完成。才能开始,所以得将下载和替换放在一个任务队列中,等待全部执行完毕,才能写入 Markdown 文件,可能已经有同学猜出来了,可以使用 Promise.all 来实现此功能。

实现代码如下

js 复制代码
// tasks 存放所有图片的 Promise
const tasks = imageElements.map((index, img) => {
  // 抛出 Promise
  return new Promise((resolve, reject) => {
    request.head(imageUrl, (err, res, body) => {
      // 此处省略部分代码
      request(imageUrl)
        .pipe(stream)
        .on('close', () => {
          // 修改元素的 src 属性
          $(img).attr('src', `./images/${filename + extname}`);
          // 在下载完成后 resolve
          resolve();
        });
    });
  });
});
// 等待其下载完成
await Promise.all(tasks)
// 此处省略写入 markdown 的代码

可以看到 Markdown 文件内的所有图片都替换了本地的图片路径。

优化+容错

到这里我们的核心需求就算解决了,当然里面还有一些细节和一些容错处理,其实还没处理完,有兴趣的同学可以接着往下看,如果需要源码的同学可以直接去 github.com/CrazyMrYan/... 获取最新爬虫代码,喜欢可以先点个 star

去掉烦人中间页

众所周知,掘金(知乎、CSDN等等)所有的链接都加上了一个烦人的中间页,每次点击跳转还来个提示。到时候部署到自己博客上去,点这个链接还会保留,那岂不是恶心自己,恶心别人吗?

掘金上被转换的链接如下

diff 复制代码
- https://link.juejin.cn?target=
+ https%3A%2F%2Fgithub.com%2FCrazyMrYan%2Fchat-audio

所以我们需要替换掉所有链接上的 https://link.juejin.cn?target=

js 复制代码
const linkElements = $('.markdown-body').find('a');
linkElements?.map((index, link) => {
  const url = $(link).attr('href')?.replace('https://link.juejin.cn?target=', '');
  $(link).attr('href', decodeURIComponent(url));
})

被替换后的链接就可以正常跳转了。

解决语言高亮丢失的问题

可以看到目前的代码转成markdown之后,表示代码块的反应号都不见了,就是就是代码界《消失的它(`*3)》;

怎么解决?我试了挺多次自定义校验规则的,但没成功,功夫不负有心人,我找到了一个大佬写的使用 turndown 的校验规则,所以就直接搬运过来了。如果你有更好的方案,可以留言私信我~

js 复制代码
// 添加名为"code"的规则
turndownService.addRule('code', {
  // 过滤需要转换的HTML节点
  filter (node, options) {
    // 选择nodeName为"PRE"并且第一个子节点的nodeName为"CODE"的节点
    return (
      node.nodeName === 'PRE' &&
      node.firstChild.nodeName === 'CODE'
    )
  },

  // 替换选定的HTML节点,将其转换为Markdown代码块
  replacement (content, node, options) {
    // 定义一个repeat函数,用于生成重复字符串
    const repeat = (str, times) => {
      return new Array(times + 1).join(str);
    }

    // 获取代码块的class属性和语言类型
    const className = node.firstChild.getAttribute('class') || '';
    const language = (className.match(/language-(\S+)/) || [null, ''])[1];
    const code = node.firstChild.textContent;

    // 定义fence字符和大小
    const fenceChar = options.fence.charAt(0);
    let fenceSize = 3;
    const fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm');

    // 计算fence大小
    let match;
    while ((match = fenceInCodeRegex.exec(code))) {
      if (match[0].length >= fenceSize) {
        fenceSize = match[0].length + 1;
      }
    }

    // 生成fence字符串,并将其包裹在代码块外
    const fence = repeat(fenceChar, fenceSize);

    return (
      '\n\n' + fence + language + '\n' +
      code.replace(/\n$/, '') +
      '\n' + fence + '\n\n'
    )
  }
});

感谢 CSDN绝不原创的飞龙大佬的《Turndown 源码解析:二、规则》,果然站在大佬的肩膀上,视野更加开阔

去掉掘金md样式

可以看到掘金在文章内容区域放了个 style 标签。但里面的内容并不是我们需要的

有了前面的经验,照猫画虎加个校验规则就行,我们只需找到style标签然后把元素的内容清空一下。

js 复制代码
filter(node, options) {
  return node.nodeName === 'STYLE'
},

replacement(content, node, options) {
  return ''
}

这样就清空了内置样式。

获取文章标题作为文件名

我们之前的文件标题都是固定的,我们需要动态获取,怎么实现呢?

很简单直接通过 title 标签拿就行。

js 复制代码
// 去掉掘金title的标志
const filename = $('title').text().replace(' - 掘金', '')?.trim();

// 写入文件
fs.writeFileSync(`docs/${filename}.md`, markdown);

这样文件名就调整好了。

容错处理

默认是没有 docs 或者 images 目录的,我们需要判断一下是否存在,没有则创建一个空目录,放在代码运行之前即可。

js 复制代码
// 创建目录
const docsDir = path.join(__dirname, 'docs');
const imagesDir = path.join(__dirname, 'docs/images');

if(!fs.existsSync(docsDir)) {
  fs.mkdirSync(docsDir);
}

if (!fs.existsSync(imagesDir)) {
  fs.mkdirSync(imagesDir);
}

最后

处理得差不多了,其实我还有个痛点需求,通过用户id,能够爬取到当前用户掘金在掘金的所有文章,而不是像我现在一样一个个的去处理。后续我会持续更新此仓库直到解决了我所有痛点。

这个有点像N年前我第一次玩 Wordpress 的那个爬取别人文章的插件一样。但是记不清插件名字了,好了今天的爬虫分享先到这里吧。

仓库地址: github.com/CrazyMrYan/...

参考文献

相关推荐
xd0000210 小时前
11. vue pinia 和react redux、jotai对比
node.js
程序猿小D11 小时前
第16节 Node.js 文件系统
linux·服务器·前端·node.js·编辑器·vim
电商API_1800790524719 小时前
构建高效可靠的电商 API:设计原则与实践指南
运维·服务器·爬虫·数据挖掘·网络爬虫
前端老六喔19 小时前
🎉 开源项目推荐 | 让你的 TypeScript/React 项目瘦身更简单!
node.js·前端工程化
醉书生ꦿ℘゜এ20 小时前
npm error Cannot read properties of null (reading ‘matches‘)
前端·npm·node.js
超级土豆粉21 小时前
从0到1写一个适用于Node.js的User Agent生成库
linux·ubuntu·node.js
waterHBO1 天前
python 爬虫工具 mitmproxy, 几问几答,记录一下
开发语言·爬虫·python
空中湖1 天前
‘pnpm‘ 不是内部或外部命令,也不是可运行的程序
npm·node.js
SailingCoder1 天前
grafana-mcp-analyzer:基于 MCP 的轻量 AI 分析监控图表的运维神器!
运维·人工智能·typescript·node.js·grafana
又又呢1 天前
前端面试题总结——webpack篇
前端·webpack·node.js