如何编写爬虫

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

准备工作

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

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

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

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

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

js 复制代码
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 环境下使用,当然也可以使用其他的请求库。

sh 复制代码
npm i cheerio axios dayjs

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

js 复制代码
// 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 中进行拦截,动态更改。

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

ts 复制代码
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 其他页面,然后储存结果
js 复制代码
// 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 的功能。

ts 复制代码
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 编写具体的爬取逻辑

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 方法

ts 复制代码
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>>>;
};
ts 复制代码
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 的形式来进行启动

yml 复制代码
// 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)的标准做法。

txt 复制代码
User-agent: *
Disallow: /private/
Allow: /public/

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

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

最后

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

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

相关推荐
红尘散仙5 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记7 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
会编程的土豆7 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
喵个咪7 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
basketball6168 小时前
Go 语言从入门到进阶:4. 数组和MAP使用方法总结
开发语言·后端·golang
qq_2518364578 小时前
SpringBoot+Vue 共享电池柜管理系统 完整实现 前后端分离项目实战 完整代码
vue.js·spring boot·后端
zhangxingchao8 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
IT_陈寒9 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
bigfootyazi10 小时前
python爬虫-基本库-urllib库(常用速查)
开发语言·爬虫·python
卷帘依旧10 小时前
v8引擎和libuv的关系
node.js