likes
comments
collection
share

如何编写爬虫

作者站长头像
站长
· 阅读数 30

这是一篇鸽了许久的文章,最近失业在家重构博客的时候发现还有一些文章没有来的及写,就有了这篇文章。

最终代码放到了 codesandbox

准备工作

编写爬虫之前需要调查一下需要爬取的网站是什么形式来渲染的

  1. 如果是 spa 页面则只需要拿到账号信息,通常是 token 或者 cookie 之类的,之后直接调取接口即可;
  2. 如果是服务器渲染返回的,则可能需要对 dom 结构解析来获取到正确的答案。

这里主要介绍场景 2,今年的时候买房就是我用爬虫 + 邮箱来定时推送,来保证获取新房的第一手信息,这里也主要结合我做的这个场景来讲解。

如何编写爬虫

观察一下页面,可以发现思路就是把 html 结构解析,然后 forEach 子项,然后添加到数组,循环这个过程就可以。

因为是 node 环境实际上是没有 dom 结构的,所以我们还需要使用特定库来完成解析,这里用的是cheerio,它提供类似 jQuery 的语法

const cheerio = require("cheerio");
const $ = cheerio.load('<h2 class="title">Hello world</h2>');

$("h2.title").text("Hello there!");
$("h2").addClass("welcome");

$.html();
//=> <html><head></head><body><h2 class="title welcome">Hello there!</h2></body></html>

最后在观察一下页面 url,发现如果跳转第二页变成了 www.hfzfzlw.com/spf/Scheme/…

所以这里很明显了,只需要更改 p 的内容就可以得到一个遍历的效果。

编写

网络请求库这里使用了 axios,它支持 web 和 node 环境下使用,当然也可以使用其他的请求库。

npm i cheerio axios dayjs

首先需要对 axios 进行一层封装

// axios.ts
import axios from "axios";
export const instance = axios.create({
  headers: {
    "User-Agent":
      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36",
  },
});

这里 User-Agent 字段是描述请求发起的设备信息,这里多准备一些,然后随机发送,例如可以使用 random-useragent,在 instance.interceptors.request.use 中进行拦截,动态更改。

之后定义一下所需的接口格式

export interface ListProps {
  id: string;
  // 详情url方便后续拓展需求
  url: string;
  // 项目名称
  entryName: string;
  // 楼栋
  building: string[];
  // 开发商
  enterpriseName: string;
  // 区域
  region: string;
  // 开始时间 number
  startTime: number;
  // 结束时间,number
  endTime: number;
  // 总数量
  total: number;
  // 状态
  registrationStatus: string;
  // 开始时间
  start: string;
  // 结束时间
  end: string;
}

剩下就是开始编写,首先:

  1. 获取初始页面内容,这里要解析第一页数据,然后查找需要循环次数;
  2. forEach 其他页面,然后储存结果
// api.ts
import { instance } from "./axios";

export const getPage = async (page = 1) => {
  const { data } = await instance.get<string>(`https://www.hfzfzlw.com/spf/Scheme/?p=${page}&xmmc=&qy=&djzt=`;)
  return data;
};

首先定义一个接口的文件,方便后续添加其他页面的接口,之后定义 utils.ts 文件,添加解析 html 的功能。

import { load } from "cheerio";
import dayjs from "dayjs";

export const BASE_URL = "http://www.hfzfzlw.com";

export interface ListProps {
  id: string;
  // 详情url方便后续拓展需求
  url: string;
  // 项目名称
  entryName: string;
  // 楼栋
  building: string[];
  // 开发商
  enterpriseName: string;
  // 区域
  region: string;
  // 开始时间 number
  startTime: number;
  // 结束时间,number
  endTime: number;
  // 总数量
  total: number;
  // 状态
  registrationStatus: string;
  // 开始时间
  start: string;
  // 结束时间
  end: string;
}

export const analysis = (html: string): ListProps[] => {
  const $ = load(html);
  const arr: ListProps[] = [];
  $("tr:not(.table_bg)").each((_i, el) => {
    // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
    const obj = {} as ListProps;

    $(el)
      .find("td")
      .each((index, item) => {
        const a = $(item).find("a");
        const value = $(item).text().trim();
        switch (index) {
          case 0:
            obj.id = $(item).find("span").text().trim();
            obj.url = `${BASE_URL}${a.attr("href") ?? ""}`;
            obj.entryName = a.text().trim();
            return;
          case 1:
            obj.building = value.split(",");
            return;
          case 2:
            obj.enterpriseName = value;
            return;
          case 3:
            obj.region = value;
            return;
          case 4:
            // eslint-disable-next-line no-case-declarations
            const [start, end] = value
              .split("至")
              .map((f) => dayjs(f.trim()).valueOf()) as [number, number];
            obj.startTime = start;
            obj.endTime = end;
            obj.start = dayjs(start).format("YYYY-MM-DD HH:mm:ss");
            obj.end = dayjs(end).format("YYYY-MM-DD HH:mm:ss");
            return;
          case 5:
            obj.total = +value;
            return;
          case 6:
            obj.registrationStatus = value;
        }
      });
    arr.push(obj);
  });
  return arr;
};

export const getTotal = (html: string) => {
  const $ = load(html);
  return +$(".green-black a")
    .eq(-3)
    .attr("href")
    .match(/p=(\d+)&/)[1];
};

之后在 index.ts 编写具体的爬取逻辑

// index.ts
import { getPage } from "./api";
import { getTotal, analysis } from "./utils";

const App = async () => {
  const html = await getPage();
  const len = getTotal(html);
  const tasks = await Promise.all(
    Array.from({ length: len - 1 }).map((_, index) => {
      return getPage(index + 2);
    })
  );
  const result = [html, ...tasks].map((f) => {
    return analysis(f);
  });
  return result;
};
App();

...404

上面状态下是理想情况,但是如果你真的这样运行会发现突然服务器没有响应了,然后你用其他 ip 的设备来访问,发现还是可以运行的。

那么问题出在哪里呢?很大概率就是被对方网站进行了拉黑,恶意爬取网站会大量占用服务器的资源和带宽,对于有经验的后端都会考虑到这种场景,对访问频繁的 IP 进行限制,例如 IP 封锁,提示验证码等。

下面就介绍一些常见绕过的方法。

限速

上面的代码我们仔细观察一下,发现其实是一下子并发很多条过去,这种场景下可能会导致触发对方的安全机制,那么换个角度来说,我们给每个任务进行限速,然后让其排队来完成是不是就可以减少被发现的概率呢。

说干就干,更新一下 utils 方法

const wait = (time: number) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(true);
    }, time);
  });
};

export const rateLimiting = async <T extends (...rest: any) => any>(
  arr: T[],
  time: number
) => {
  let i = 0;
  const result: ReturnType<T>[] = [];
  for (const iterator of arr) {
    const o = await iterator();
    result.push(o);
    if (++i < arr.length - 1) {
      await wait(time);
    }
  }

  return result as Array<Awaited<ReturnType<T>>>;
};
import { getPage } from "./api";
import { getTotal, analysis, rateLimiting } from "./utils";

const App = async () => {
  const html = await getPage();
  const len = getTotal(html);
  const arr = Array.from({ length: len - 1 }).map((_, index) => {
    return () => getPage(index + 2);
  });
  const tasks = await rateLimiting(arr, 3000);
  const result = [html, ...tasks].map((f) => {
    return () => analysis(f);
  });
  return result;
};

ok,这样就完成了限速相关的编写,当然实际场景中还需要考虑重试等机制。那么除了限速还有其他方式吗?

IP 池

除了上面的方式,我们还可以维护一个 ip 来进行操作,例如我有大概 100 个代理 IP

  1. 维护一个队列,每次请求之后记录爬取时间和响应时间;
  2. 依次请求,排除掉正在爬取的代理 IP;
  3. 之后对照响应的时间 - 现在时间,如果大于正常人类浏览时间就继续下一次;

这里推荐几个我正在使用的代理池,推荐使用 docker 的形式来进行启动

// docker-compose.yml
version: '2'
services:
  proxy1:
    image: 'jhao104/proxy_pool'
    ports:
      - '5010:5010'
    depends_on:
      - proxy_redis
    environment:
      DB_CONN: 'redis://@proxy_redis:6379/0'

  proxy_redis:
    image: 'redis'

  proxy2:
    image: 'boses/ipproxypool'
    restart: always
    privileged: true
    ports:
      - 8000:8000

具体如何维护代理池,然后请求重试这里就不一一写出来了,如果有兴趣可以看我写的这个项目 Hefei-NewHouse

如果为了稳定也可以考虑一些付费的IP池,对于验证码之类的措施可以接入到验证码平台,当然这个是收费的。

robots.txt

在编写爬虫中,需要注意一下对方网站根目录是否存在 robots.txt 文件,这个相当于一个默认规则来告诉网络爬虫哪些页面可以被抓取,哪些不应该被抓取。这是遵循网络爬虫协议(Robots Exclusion Protocol)的标准做法。

User-agent: *
Disallow: /private/
Allow: /public/

上述示例表示,对于所有爬虫(User-agent: *),不允许访问 "/private/" 目录下的页面,但允许访问 "/public/" 目录下的页面。

虽然这个不是强制的,但是还是建议遵循这个规则,否则出现法律相关问题可能蹲局子。

最后

这里只简单介绍了一些编写爬虫的规则,对于规则 1 没有进行额外的拓展,有兴趣的小伙伴可以思考一下知乎如何写一个爬虫。

最后提醒一下,在法律层面上,未经允许的爬取可能违反计算机犯罪法、数据保护法或其他相关法规,这可能导致法律责任。因此,最好在进行任何形式的爬取之前,仔细阅读目标网站的使用条款和服务协议,并确保你的行为是合法的。

转载自:https://juejin.cn/post/7301909270906699802
评论
请登录