【NestJS全栈之旅】应用篇:通用爬虫服务三两事儿

新书全栈实战项目:数字门店管理平台开源啦🎉🎉🎉

GitHub地址(持续更新NestJS企业级实践):欢迎star⭐️⭐️⭐️

前端React+TypeScript+Vite

后端Nest+MySQL+Redis+Docker

大家好,我是元兮。

2024.9月我发布了新书《NestJS全栈开发解析:快速上手与实践》并开源了书中的实战项目代码。其中需要特别说明的是,项目面向的是快速上手的基础人群,当然还有需要持续迭代的地方,比如MySQL事务篇使用MQ进行异步和流量削峰如何使用Nest实现爬虫服务实现商品数据的Excel导入导出✅Nest集成飞书服务解析多维表格Nest集成飞书实现每日数据统计等等,细水长流,我们一步步来。

背景

最近阿兮吧遇到一个任务,需要写一个通用爬虫 ,目标是能够支持不同电商网站的关键信息提取,比如title、description、keywords、productImages(站点主图)、pageContent(页面内容)等等,然后将这些信息投喂给LLM去解析并生成效果图。

其中,productImages是本文的重头戏,我们在实现的过程中会采用各种不同的策略来获取站点图片,将在后文详细介绍。

当然了,对于想要什么样的页面信息根据业务需求进行自定义,套路都是一样的。

爬虫知多少

要知道,爬与反爬是一种博弈,与安全是一样的,爬虫运行过程中会遇到各种反爬的站点,此时就需要采取其他措施进行应对。

话说回来,论爬虫这事儿,还是Python老哥在行啊,行业内大部分的爬虫任务都是通过py来做的,能够应对各种类型的爬虫需求和反爬手段,归结于人家的生态繁荣。

尽管如此,不得不陈述一个不争的事实,要想实现一个通用爬虫,绝不是一件容易的事儿,难点不在于技术有多么高深,而是互联网的站点千奇百怪,站点的结构也变幻莫测,很难有一个标准的规则来应用各种站点。

常用爬虫策略

介绍爬虫策略之前,我们先来了解一下页面站点类型,主要可以概括为两大类:静态页面站点和动态页面站点

顾名思义,静态站点的网页结构是固定的,浏览器在加载整个HTML页面回来之后,它就是一个完整的DOM结构,这种可以纯粹的HTML静态页面,或者是通过JSP、SSR、SSG等技术从服务端动态渲染的。

Next.js服务端渲染出来的页面为例,呈现了页面的所有节点,这种站点对SEO非常友好,本质上也是为了服务好爬虫

相应地,动态页面站点指的是通过JS脚本、前端Vue|React动态渲染出来的,页面的数据绝大部分是通过异步加载的形式进行渲染,其本身HTML结构非常简单。例如通过Vue渲染的页面原始HTML结构,只有一个div结构,页面所有内容都会动态渲染到这个节点中。

其实很好理解,我们知道蜘蛛是需要在蜘蛛网上捕食,拥有完整的DOM结构可以让蜘蛛更容易活动。

好,回到话题上。

自定义爬虫在应对这两种站点时,采用的爬取策略也稍微不同。对于静态站点,通过发送http请求到目标站点,可以将整个HTML文本拉回来,进而对HTML进行操作,例如通过正则匹配或者开源爬虫组件,获取目标数据。对于接口数据也是如此,发送指定接口http请求以获取相应数据。

这个方法简单粗暴,缺点就是无法对JS动态渲染的页面进行内容爬取,同时目标接口如果加入了签名或登录态验证,结果返回401权限不足。显然对于实现通用爬虫这个目标,是行不通的。

那面对动态站点,应该怎么办呢?答案是自动化爬网程序

Node中,这类自动化程序最为代表性的是PlaywrightPuppeteer,本质上它们运行时都会启动无头或有头浏览器实例,通过CDP(Chrome DevTools Protocol)协议与浏览器进行通信,控制浏览器行为以达到自动化操作的目的。

从专业的爬虫角度来讲,Playwright的兼容性更好,能够模拟各种内核的浏览器和多端行为,并且提供了多线程爬虫和反爬策略,如IP代理、模拟用户行为等,能够满足更多的爬虫场景。

而对于Puppeteer,我们可以利用它实现自动化测试、自动打开并调试页面代码。

常用反爬策略

所谓上有政策,下有对策。目标站点需要做的就是反爬虫,防止自身站点数据被恶意薅羊毛或用于不正当用途,除此之外爬虫程序还能对服务器资源带来压力,高并发的爬虫程序甚至能够将服务器干宕机。显然,作为天选打工人张三肯定不能坐以待毙,服务器与我势必共进退,张三虫虫大战一触即发。

张三:好,既然你小子短时间内发送大量请求来搞我,那我先把你这个IP 加上速率限制先,如十分钟内只能请求20次,超过了就把你关进小黑屋冷静冷静先,后续再打过来的请求直接拒绝,等过了这个冷静期再把你放出来。

虫虫:你关任你关,我还能发光。 既然你限制我这个IP,行!劳资搞个代理IP,IP 地址来自不同的地理位置和网络提供商,每次请求的时候对IP进行随机选择,尽可能减少短时间内大量请求来自同一IP的场景。

张三:有点东西,但东西不多。 既然你可以通过切换ip来模拟用户请求,那不得不在请求接口的时候,你得把我生成的图片验证码传过来,如果请求参数中传递的验证码与服务器生成在缓存中的验证码对不上,那我就知道你是"冒牌"的。

恩,看似没问题,但没过几天张三突然被通知说验证码被自动识别了,爬虫通过OCR技术将验证码自动转为文本发送到服务器,从而实现了批量请求。

OCR技术是一种将图片中的文本内容转换为可编辑和可搜索的文本格式的技术。它广泛应用于文档数字化、自动化数据录入和信息提取等领域。对于也有开源组件可以实现这一点。

张三:你他娘的还真是个人才! 行,既然如此,那我就搞个滑动验证码,用户需手动拖动完成图像匹配才能完成验证。

不仅于此,聪明的张三为了防止爬虫计算出滑块位置并模拟人类滑动行为,达到绕过验证码效果,前后端还采用非对称加密的方式生成一个key,私钥由服务器进行保存。滑动图案匹配上了,前端通过公钥进行加密后,将key传递到服务端,再使用私钥进行解密。

没错,这种通常是用于接口反爬场景,而对于站点反爬,某些站点还会要求第一次访问时,采用重定向的方式跳转到指定页面进行验证操作,以达到反爬效果。比如这两种验证码:

Google的reCAPTCHA
Amazon的captcha:
shopify的captcha:

老板:为了不影响用户体验和网站排名,我们站点还需要进行SEO的,访问的时候不要跳转到其他站点,能不能有其他解决办法啊?

张三:为了增加爬虫难度,张三为此提出主动加班,给站点增加了一些可识别的信息用于标识网线另一端的是真的人类行为。 那什么能够标识唯一性呢?答案是指纹!

打工人每天上班会使用指纹打卡,屏幕解锁也是用指纹,支付密码也是用指纹。那浏览器也可以有自己的指纹,每个用户通过浏览器发送请求的时候,顺便把指纹信息发过来,通过判断用户的设备信息(如操作系统、浏览器类型、屏幕分辨率等),不就可以知道对方是不是正常人类行为?

没错!用于指纹识别的大部分属性都存储在windown.navigator下,该对象包含有关用户状态和身份的方法和信息,这些属性都可以对设备进行指纹识别。

当页面初始化时,前端通过JavaScript获取到指纹相关信息,发送给服务端,服务端可以通过Redis进行持久化指纹信息。再次刷新页面,服务端判断当前携带的指纹信息与缓存中的指纹信息相似度,同时进行访问频率判断是否为爬虫的批量行为。伪代码如下:

ts 复制代码
app.post('/api/verify-fingerprint', (req, res) => {
  const { fingerprint } = req.body;
  const currentTime = Date.now();

  // 判断指纹是否已存在
  if (fingerprints[fingerprint]) {
    const lastAccess = fingerprints[fingerprint].lastAccess;

    // 如果访问频率过高,视为爬虫
    if (currentTime - lastAccess < 3000) { // 3秒内再次访问
      return res.status(429).send('Too many requests');
    }
  }

  // 更新指纹的最后访问时间
  fingerprints[fingerprint] = { lastAccess: currentTime };
  res.send('Access granted');
});

虫虫:no no no !难不倒我。这些伎俩虽然能阻止很大部分低级爬虫,但别忘了我可是高级爬虫。我在爬虫在创建新的浏览器上下文时,模拟不同的浏览器指纹特征,如用户代理、时区、屏幕分辨率、设备模拟等。通过这些功能,可以有效地修改和模拟浏览器指纹,以便实现伪装或者多样化的用户行为模拟(后文中进行介绍)。

张三:好好好,这么玩是吧...草!我先去买点橘子,你在原地等我别走开。

说了这么多,爬与反爬是一场拉锯战,双方都在想方设法给对方制造麻烦、提高成本,使得ROI(投入产出比)失衡。总结一下以上常用的反爬手段有哪些?

  1. ip速率限制
  2. 图形验证码、滑动验证码
  3. 浏览器指纹

实现爬虫服务

为了演示爬虫效果,我们使用palywright的无头浏览器特性起一个实例,模拟用户进行内容获取,以开源你项目store-web-backend项目为例。

common下面创建一个crawler模块,controller中定义接口路径,service中实现具体的爬虫逻辑。

要想通过node实现爬虫服务,需要安装以下依赖:@crawlee/playwright playwright cheerio,其中playwright是包装了各种浏览器内核的自动化程序,cheerio是node端的jQuery,可以对页面节点进行类Dom操作。

安装完之后,需要执行npx playwright install --with-deps命令下载各种浏览器内核到本地,这里需要耗费几分钟时间。

来看CrawlerController的路由规则:

ts 复制代码
import { Controller, Get, Query } from '@nestjs/common';
import {
  CrawlerResult,
  CrawlerService,
} from 'src/common/crawler/crawler.service';
import { AllowNoToken } from 'src/common/decorators/token.decorator';

@Controller('crawler')
export class ExcelController {
  constructor(private readonly excelService: CrawlerService) {}

  @Get('/analysis-url')
  @AllowNoToken()
  async analysisUrl(@Query('url') url: string): Promise<CrawlerResult> {
    return this.excelService.extractUrlInfo(url);
  }
}

再来看CrawlerService的业务实现逻辑:

ts 复制代码
import { Injectable } from '@nestjs/common';
import { PlaywrightCrawler } from '@crawlee/playwright';
import * as cheerio from 'cheerio';

export interface CrawlerResult {
  title: string;
  subtitle: string;
  keywords: string;
  description: string;
  headline: string;
  punchline: string;
  ogData: {
    [key in string]: any;
  };
  cta: string;
  product_images: (string | number | Record<string, string | number>)[];
  pageText: string;
  originUrl: string;
}
@Injectable()
export class CrawlerService {
  async extractUrlInfo(url: string): Promise<CrawlerResult> {
    let result: CrawlerResult;
    let crawler = new PlaywrightCrawler({
      minConcurrency: 5,
      // 最大并发数
      maxConcurrency: 10,
      maxRequestsPerMinute: 20,
      // 页面被拦截导致的失败时不重试
      retryOnBlocked: false,
      // 请求失败重试次数
      maxRequestRetries: 0,
      // requestHandler请求超时时间
      requestHandlerTimeoutSecs: 20,
      launchContext: {
        launchOptions: {
          headless: true,
          args: [
            '--webrtc-ip-handling-policy=disable_non_proxied_udp',
            '--disable-blink-features=AutomationControlled',
            '--force-webrtc-ip-handling-policy',
          ],
        },
        // userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
        // 无痕模式
        useIncognitoPages: true,
      },
      // 处理请求
      requestHandler: async ({ page, request }) => {
        try {
          const bodyHtml = await page.evaluate(() => document.body.innerHTML);
          const $ = cheerio.load(bodyHtml);

          // 提取标题
          const title = (await page.title()) || '';

          // 提取子标题(假设在h2标签中)
          const subtitle = await page
            .$eval('h2', (el) => el.textContent)
            .catch(() => '');

          await page.evaluate(() => {
            window.scrollBy(0, 300); // 向下滚动 300px
          });
          await page.waitForTimeout(500);
          await page.evaluate(() => {
            window.scrollBy(0, 0); // 回到顶部
          });

          // 提取所有img标签
          const all_product_images = await page.$$eval('img', (imgs) =>
            imgs
              .map((img) => {
                // 兼容懒加载的情况,优先判断从 data-srcset 或 srcset 中提取最大的图片,没有则取 src
                const srcset =
                  img.srcset || img.getAttribute('data-srcset') || '';
                const regex = /(\S+)\s+(\d+)w|x/g;
                const matches = [...srcset.matchAll(regex)];
                const srcList = srcset
                  ? matches
                      .map((match) => ({
                        url: match[1],
                        width: parseInt(match[2], 10),
                      }))
                      .sort((a, b) => a.width - b.width)
                  : [];

                let src = img.src;
                let x = img.getBoundingClientRect().x;
                let y = img.getBoundingClientRect().y;
                if (srcList.length) {
                  const { url } = srcList[srcList.length - 1];
                  src = url;
                  x = y = 0;
                }
                return {
                  src,
                  alt: img.alt,
                  renderWidth: img.clientWidth,
                  renderHeight: img.clientHeight,
                  // 图片所处可视区域x y坐标
                  x,
                  y,
                };
              })
              .filter(
                (img) =>
                  !!img.src &&
                  !img.src.includes('gif') &&
                  !img.src.includes('1x1') &&
                  !/^data:image\/[a-zA-Z0-9+]+;base64,/.test(img.src) &&
                  0.5 <= img.renderWidth / img.renderHeight &&
                  img.renderWidth / img.renderHeight <= 2,
              ),
          );
          // 提取所有带有 background-image 的元素
          const all_background_images = await page.$$eval('div', (elements) =>
            elements
              .map((el) => {
                const style = window.getComputedStyle(el);
                const backgroundImage =
                  style.getPropertyValue('background-image');
                const x = el.getBoundingClientRect().x;
                const y = el.getBoundingClientRect().y;
                const renderWidth = el.clientWidth;
                const renderHeight = el.clientHeight;

                // 提取背景图的 URL
                const backgroundImageUrl = backgroundImage.match(
                  /url\(["']?([^"']*)["']?\)/,
                )?.[1];

                return {
                  src: backgroundImageUrl,
                  x,
                  y,
                  renderWidth,
                  renderHeight,
                };
              })
              .filter(
                (el) =>
                  !!el.src &&
                  !el.src.includes('gif') &&
                  !el.src.includes('1x1') &&
                  !el.src.includes('data:image/png;base64') &&
                  0.5 <= el.renderWidth / el.renderHeight &&
                  el.renderWidth / el.renderHeight <= 2,
              ),
          );

          const filter_product_images = [
            ...all_product_images,
            ...all_background_images,
          ].filter((img) => {
            // 过滤掉图片在可视区域之外的图片
            return (
              -1000 <= img.x && img.x <= 2500 && -500 <= img.y && img.y <= 1000
            );
          });

          let product_images =
            filter_product_images.length >= 5
              ? filter_product_images
              : [...all_product_images, ...all_background_images];
          // 选择前n个最大的图片
          product_images.sort((a, b) => {
            return (
              b.renderWidth * b.renderHeight - a.renderWidth * a.renderHeight
            );
          });

          product_images = product_images.slice(0, 10);

          if (!product_images.length)
            return Promise.reject('No product images found');

          // 获取主标题
          const headline = $('h1').first().text() || '';

          // 提取Punchline
          const possiblePunchlines = ['.punchline', 'h2', 'p'];
          let punchline = '';
          for (const selector of possiblePunchlines) {
            punchline = $(selector).first().text().trim();
            if (punchline) break;
          }

          // 提取OG数据
          const ogData = {};
          $('meta[property^="og:"]').each((_, element) => {
            const property = $(element).attr('property');
            const content = $(element).attr('content');
            if (property && content) {
              ogData[property] = content;
            }
          });

          // 提取CTA (通过内容推断)
          const possibleCTAs = ['button', 'a'];
          const ctaKeywords = [
            '购买',
            '立即购买',
            '加入购物车',
            'Buy',
            'Add to Cart',
            'Add to bag',
          ];
          let cta = '';
          for (const selector of possibleCTAs) {
            $(selector).each((i, el) => {
              const text = $(el).text().trim();
              if (ctaKeywords.some((keyword) => text.includes(keyword))) {
                cta = text;
                return false;
              }
            });
            if (cta !== '') break;
          }

          // 提取关键词
          const keywords = await page
            .$eval('meta[name="keywords"]', (el: HTMLMetaElement) => el.content)
            .catch(() => '');

          // 提取描述
          const description = await page
            .$eval(
              'meta[name="description"], meta[property="og:description"]',
              (el: HTMLMetaElement) => el.content,
            )
            .catch(() => '');

          // 获取页面的文本内容,过滤掉 CSS、换行符、内联脚本、样式和图像等
          let pageText = await page.evaluate(() => {
            // 移除所有 <style> 元素
            document.querySelectorAll('style').forEach((el) => el.remove());

            // 移除所有 <script> 元素
            document.querySelectorAll('script').forEach((el) => el.remove());
            document.querySelectorAll('noscript').forEach((el) => el.remove());

            // 移除所有 <img> 元素
            document.querySelectorAll('img').forEach((el) => el.remove());

            // 返回页面的纯文本内容
            return document.body.textContent;
          });

          await page.close();

          pageText = pageText
            .replace(/\n/g, '')
            .replace(/\s{2,}/g, ' ')
            .trim();

          // 保存提取的数据
          result = {
            title,
            subtitle,
            keywords,
            description,
            headline,
            punchline,
            ogData,
            cta,
            product_images,
            pageText,
            originUrl: url,
          };
        } catch (error) {
          throw new Error(error.message);
        }
      },
      errorHandler: ({}, error) => {
        return Promise.reject(error.message);
      },

      // 请求失败处理
      failedRequestHandler: ({}, error) => {
        return Promise.reject(error.message);
      },
    });

    // 添加URL到爬虫队列,uniqueKey为请求的唯一标识
    await crawler.addRequests([{ url, uniqueKey: Math.random().toString() }]);

    await crawler.run();

    crawler = null;

    return Object.keys(result).length > 0
      ? Promise.resolve(result)
      : Promise.reject();
  }
}

代码注释已经很清楚了,前面提到图片是重头戏,我们需要获取到页面中主图,考虑到站点的图片是由img标签background-image背景 生成,同时图片有即时加载 的,还有异步懒加载(data-srcset、srcset) 的,所以需要对各种场景进行兼容。

有些站点需要用户滚动后或者滚动到一定位置才会触发懒加载,此时才能真正加载图片,那怎么办呢?答案是让爬虫模拟用户行为。 例如这段代码:

ts 复制代码
  await page.evaluate(() => {
    window.scrollBy(0, 300); // 向下滚动 300px
  });
  await page.waitForTimeout(500);
  await page.evaluate(() => {
    window.scrollBy(0, 0); // 回到顶部
  });

但是你滚动之后,我图片的可视区位置就不准了呀,这样导致判断图片位置有误差,咋办呢?答案是再滚回去! 所以上面会重新回到顶部位置。

除此之外,需要对图片大小进行排序过滤,过滤掉base64的小图、占位图等等,并按照从大到小的顺序进行输出,大小是通过渲染宽高决定的。

到这就行了吗?不行! 还需要过滤掉定位在屏幕之外的图片,比如很多有swiper的场景。同时,还需要过滤掉宽高比例异常的,比如很长或很宽的图片。

当然了,这些规则完全取决于你的需求,请尽管发挥你的想象力!这里就不再详细解释每行代码的意义。

最后在AppModule中引入新模块:

运行后通过.http中请求接口试试效果:

点击send request后,可以在控制台看到已经成功启动了爬虫实例:

接着就把JD的我这本书的商品信息获取下来了

访问下图片,确认是商品主图(广而告之)。

至此我们完成了爬虫服务编写,有同学可能会问,JD应该是由反爬策略的呀,为啥你这个可以获取到呢?

没错!的确是有做,而且是通过指纹来做的。为什么?

我们把上面service方法中44行注释打开,意味着我们手动指定当前指纹信息userAgent,会发生什么?重新发送请求试试。

出现了JD验证码页面!!!把无头浏览器改为有头浏览器 headless: false,此时就可以看到这个页面了。

这是因为人家有做指纹限制 啊,我们用了自定义的指纹信息,跟人家的没匹配上,自然就被拦截了。所以得出一个结论,使用playwright默认的指纹策略就行了。

异步更合理

爬虫任务是一个耗时的操作,通常会先启动爬虫实例,这个实例就是我们众所周知的无头浏览器,它会加载目标URL并解析页面为page对象,通过操作这个page对象进而拿到各种页面信息,所以,如果目标站点加载过程中出现网络波动或服务器延迟等情况,对应爬虫的返回结果耗时就更长。

当爬虫接口存在出现并发请求时,爬虫任务就会堆积并占用大量的内存空间,服务器资源可以在短时间内被用完导致程序异常,为避免这种情况,我们通常使用MQ来消费爬虫任务进行流量削峰。

除此之外,仅仅通过异步来管理任务还有问题,由于任务会堆积,短时间内无法消费任务就会导致有多个浏览器实例被启动,也会瞬间把内存吃完,为此我们还需要限制一定时间内允许启动的爬虫数量。

这部分内容等我们单独介绍MQ的时候再进行介绍~

总结

至此,我们实现了一个相对通用的爬虫服务,满足了大部分站点的信息获取。

过程中你可以看到,爬与反爬并没有赢家,这个过程无非是让开发人员的工作变得困难和不愉快是可能的。必须承认的是,这个通用服务不是万能的,它依旧对很多站点的反爬策略无能为力,比如Amazon、Apple store等。针对这些站点,我们可以使用Python的能力进行定制化爬虫策略,以满足业务需求。

相关推荐
小满zs36 分钟前
Next.js精通SEO第二章(robots.txt + sitemap.xml)
前端·seo
kyriewen41 分钟前
你的首屏慢得像蜗牛?这6招让页面“秒开”
前端·面试·性能优化
段小二43 分钟前
服务一重启全丢了——Spring AI Alibaba Agent 三层持久化完整方案
java·后端
UIUV1 小时前
Go语言入门到精通学习笔记
后端·go·编程语言
lizhongxuan1 小时前
开发 Agent 的坑
后端
段小二1 小时前
Agent 自动把机票改错了,推理完全正确——这才是真正的风险
java·后端
算是难了1 小时前
Nestjs学习总结_3
前端·typescript·node.js
itjinyin1 小时前
ShardingSphere-jdbc 5.5.0 + spring boot 基础配置 - 实战篇
java·spring boot·后端
yogalin19931 小时前
如何实现一个简化的响应式系统
前端
kyriewen111 小时前
项目做了一半想重写?这套前端架构让你少走3年弯路
前端·javascript·chrome·架构·ecmascript·html5