简介

CDP全称Chrome DevTools Protocol,是一种控制开发者工具的协议。可以通过已开放的功能,来间接操纵浏览器完成一系列自动化工作。

我使用的工具

  • chromedp 是一个使用Golang封装CDP的项目,它的功能庞大,包括一些常用的元素点击、滑动事件等等,如果是做 自动化测试 的话,选用它更加合适。
  • mafredri/cdp 也是一个使用Golang封装CDP的项目,但是它仅仅是做了封装,并保持整体API风格的统一,减少我们使用过程中的不适。本文将使用这个框架进行开发。
  • headless-shell 是一个基于debian的无界面chrome浏览器的docker镜像,可以集成到我们常用的微服务架构里。

搭建使用CDP的整体框架

虽然mafredri/cdp提供了最基础的使用方法,但是我们需要在它的基础上修改,以便能满足我们的需求,和持续稳定爬取的要求。

初始化与开发者工具的远程连接

var (
  cdpHost = "127.0.0.1"
  cdpPort = "9222"
  devt *devtool.DevTools
)

func init() {
  devt = devtool.New(fmt.Sprintf("http://%s:%d", cdpHost, cdpPort))
}

通过访问页面来获取所有请求返回的数据

func Run(url string) {
  ctx, cancel := context.WithTimeout(context.Background(), timeout) //该页面的超时时间,最好增加多一些缓冲时间,不然页面线程可能比获取数据的线程早退出
  defer cancel()

  //打开一个空白页
  pt, err := devt.Create(ctx)
  if err != nil {
    return
  }
  defer devt.Close(ctx, pt) //退出时关闭

  conn, err := rpcc.DialContext(ctx, pt.WebSocketDebuggerURL)
  if err != nil {
    return
  }
  defer conn.Close()

  c := cdp.NewClient(conn)

  // 该注释部分是模拟某种设备
  // err = c.Emulation.ClearDeviceMetricsOverride(ctx)
  // if err != nil {
  // 	return
  // }

  // err = c.Emulation.SetDeviceMetricsOverride(ctx,
  // 	&emulation.SetDeviceMetricsOverrideArgs{
  // 		Width:						 config.Device.WindowWidth,
  // 		Height:						config.Device.WindowHeight,
  // 		DeviceScaleFactor: 1,
  // 		Mobile:						config.Device.Mobile})
  // if err != nil {
  // 	return
  // }

  domContent, err := c.Page.DOMContentEventFired(ctx)
  if err != nil {
    return
  }
  defer domContent.Close()

  // 批量处理错误
  if err = runBatch(
    func() error { return c.DOM.Enable(ctx) },
    func() error { return c.Network.Enable(ctx, nil) },
    func() error { return c.Page.Enable(ctx) },
  ); err != nil {
    return
  }

  // 监听每个请求收到的数据
  go func() {
    responseReceived, err := c.Network.ResponseReceived(ctx)
    if err != nil {
      return
    }
    defer responseReceived.Close()
    ticker := time.NewTicker(timeout)
    defer ticker.Stop()
    for {
      select {
      case <-ticker.C:
        return
      case <-ctx.Done():
        return
      case <-responseReceived.Ready(): //每个请求获得的数据
        ev, err := responseReceived.Recv()
        if err != nil {
          time.Sleep(time.Millisecond * 100)
          break
        }
        if bodyReply, err := c.Network.GetResponseBody(ctx, &network.GetResponseBodyArgs{RequestID: ev.RequestID}); err == nil {
          //异步执行回调任务
          // go func() {
          // 	callback(ev, bodyReply.Body)
          // }()
        }
      }
    }
  }()
  _, err = c.Page.Navigate(ctx, page.NewNavigateArgs(url))
  if err != nil {
    return
  }
  time.Sleep(timeout)
  // 此处后加入你需要进行的操作,一个网站加载所有请求,和渲染页面都需要时间,	超时时间需要酌情设置。
  // 比如点击事件啥的玩意
}

type runBatchFunc func() error

func runBatch(fn ...runBatchFunc) error {
  eg := errgroup.Group{}
  for _, f := range fn {
    eg.Go(f)
  }
  return eg.Wait()
}

其实前面的操作已经可以获取到页面中所有请求返回的数据了,剩下的就是根据接口数据的特征来提取想要的数据。

封装点击事件

其实如果要更多功能,直接用chromedp可能是一个更好的选择。但是我们如果使用了mafredri/cdp,我们也可以参考chromedp的实现方式来封装一些常用功能。

点击某个点

func Click(clt *cdp.Client, x, y float64) (err error) {
  ts := input.TouchPoint{X: x, Y: y}
  err = clt.Input.DispatchTouchEvent(clt.Ctx, &input.DispatchTouchEventArgs{
    Type:        "touchStart",
    TouchPoints: []input.TouchPoint{ts},
  })
  if err != nil {
    return
  }
  err = clt.Input.DispatchTouchEvent(clt.Ctx, &input.DispatchTouchEventArgs{
    Type:        "touchEnd",
    TouchPoints: []input.TouchPoint{},
  })
  if err != nil {
    return
  }
  return
}

点击某个元素

func ClickElement(clt *cdp.Client, el, text string) (err error) {
  expression := fmt.Sprintf(`
function contains(selector, text) {
  var elements = document.querySelectorAll(selector);
  return Array.prototype.filter.call(elements, function(element){
    return RegExp(text).test(element.textContent);
  });
}
contains('%s', '%s').forEach((value, index, array) => {
  value.click();
});
`, el, text)
  evalArgs := runtime.NewEvaluateArgs(expression)
  _, err = clt.Runtime.Evaluate(c.Ctx, evalArgs)
  if err != nil {
    return
  }
  return
}

额外的话

如果要搭建好整个基于cdp的爬虫,当然要做很多封装的工作。比如我们可以封装一台IPhone6 😏

type Device struct {
  WindowWidth  int
  WindowHeight int
  Mobile       bool
}

func IPhone6() Device {
  return Device{
    WindowWidth:  375,
    WindowHeight: 667,
    Mobile:       true,
  }
}

启动一个爬虫任务

func init() {
  NewCrawler(
    NewDriver(Config{Timeout: timeout, Device: IPhone6()}),
  ).Run()
}