render

Puppeteer 中如何绕过无头浏览器检测

执行以下代码:

# credits: https://intoli.com/blog/making-chrome-headless-undetectable/

import pyppeteer as pp

from futile2.http import get_random_desktop_ua

HIDE_SCRIPTS = dict(
    hide_webdriver="""
() => {
    Object.defineProperty(navigator, "webdriver", {
      get: () => false,
    });
  }
""",
    hide_navigator="""
() => {
    // We can mock this in as much depth as we need for the test.
    window.navigator.chrome = {
      app: {
        isInstalled: false,
      },
      webstore: {
        onInstallStageChanged: {},
        onDownloadProgress: {},
      },
      runtime: {
        PlatformOs: {
          MAC: "mac",
          WIN: "win",
          ANDROwp_id: "android",
          CROS: "cros",
          LINUX: "linux",
          OPENBSD: "openbsd",
        },
        PlatformArch: {
          ARM: "arm",
          X86_32: "x86-32",
          X86_64: "x86-64",
        },
        PlatformNaclArch: {
          ARM: "arm",
          X86_32: "x86-32",
          X86_64: "x86-64",
        },
        RequestUpdateCheckStatus: {
          THROTTLED: "throttled",
          NO_UPDATE: "no_update",
          UPDATE_AVAILABLE: "update_available",
        },
        OnInstalledReason: {
          INSTALL: "install",
          UPDATE: "update",
          CHROME_UPDATE: "chrome_update",
          SHARED_MODULE_UPDATE: "shared_module_update",
        },
        OnRestartRequiredReason: {
          APP_UPDATE: "app_update",
          OS_UPDATE: "os_update",
          PERIODIC: "periodic",
        },
      },
    };
  }
""",
    hide_permission="""
() => {
    const originalQuery = window.navigator.permissions.query;
    return window.navigator.permissions.query = (parameters) => (
      parameters.name === "notifications" ?
        Promise.resolve({ state: Notification.permission }) :
        originalQuery(parameters)
    );
  }
""",
    hide_plugins_length="""
() => {
    // Overwrite the `plugins` property to use a custom getter.
    Object.defineProperty(navigator, "plugins", {
      // This just needs to have `length > 0` for the current test,
      // but we could mock the plugins too if necessary.
      get: () => [1, 2, 3, 4, 5],
    });
  }
""",
    hide_language="""
() => {
    // Overwrite the `plugins` property to use a custom getter.
    Object.defineProperty(navigator, "languages", {
      get: () => ["en-US", "en"],
    });
  }
""",
    hide_webgl_renderer="""
() => {
    const getParameter = WebGLRenderingContext.getParameter;
    WebGLRenderingContext.prototype.getParameter = function(parameter) {
      // UNMASKED_VENDOR_WEBGL
      if (parameter === 37445) {
        return "Intel Open Source Technology Center";
      }
      // UNMASKED_RENDERER_WEBGL
      if (parameter === 37446) {
        return "Mesa DRI Intel(R) Ivybridge Mobile ";
      }

      return getParameter(parameter);
    };
}
""",
    hide_broken_image="""
() => {
    ["height", "width"].forEach(property => {
      // store the existing descriptor
      const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, property);

      // redefine the property with a patched descriptor
      Object.defineProperty(HTMLImageElement.prototype, property, {
        ...imageDescriptor,
        get: function() {
          // return an arbitrary non-zero dimension if the image failed to load
          if (this.complete && this.naturalHeight == 0) {
            return 20;
          }
          // otherwise, return the actual dimension
          return imageDescriptor.get.apply(this);
        },
      });
  });
}
""",
    hide_modernizr="""
() => {
    // store the existing descriptor
    const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight");

    // redefine the property with a patched descriptor
    Object.defineProperty(HTMLDivElement.prototype, "offsetHeight", {
      ...elementDescriptor,
      get: function() {
        if (this.id === "modernizr") {
            return 1;
        }
        return elementDescriptor.get.apply(this);
      },
    });
}
""",
)


async def get_headless_page(*args, **kwargs):
    """
    生成一个无法检测的浏览器页面
    """
    browser = await pp.launch(*args, **kwargs)
    page = await browser.newPage()
    await page.setUserAgent(get_random_desktop_ua())
    for script in HIDE_SCRIPTS.values():
        await page.evaluateOnNewDocument(script)

    return page

使用 Puppeteer

在服务器上部署 puppeteer 现在有两个问题:

  1. 如何打包 data-dir 上去
  2. 部署使用 Docker 还是直接手工跑

puppeteer 的相关资料:

  1. API 文档。https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md
  2. Browserless 的 Docker 镜像。https://docs.browserless.io/docs/docker-quickstart.html
  3. PP Cluster。https://github.com/thomasdondorf/puppeteer-cluster
  4. Awesome Puppeteer。https://github.com/transitive-bullshit/awesome-puppeteer
  5. GitHub Topic。https://github.com/topics/puppeteer

无头浏览器和 Puppeteer 的一些最佳实践

在做爬虫的时候,总会遇到一些动态网页,他们的内容是 Ajax 加载甚至是加密的。虽然说对于一些大站来说,分析接口是值得的,但是对于众多的小网站来说,一个一个分析接口太繁琐了,这时候直接使用浏览器渲染就简单得多了。

以往比较流行的是 selenium + phantomjs 的组合,不过在自从 Google 官方推出了谷歌浏览器的无头模式和 puppeteer 这个库以后,稳定性和易用度都大幅得到了提升,本文也主要探讨谷歌浏览器和 puppeteer。另外 puppeteer 也有第三方的 Python 移植,叫做 pyppeteer,不过这个库目前来看不太稳定(个人使用体验)。另外 pyppeteer 这个库使用了 asyncio,如果你的爬虫使用的是普通的同步语法,那么也还是不方便调用 pyppeteer 这个库,个人建议还是使用官方的 node 版 puppeteer,如果需要在 Python 中调用,直接调用 node 然后渲染就可以了。

browserless 是一家在提供云端浏览器渲染服务的公司,本文翻译了他们关于如何提升无头浏览器稳定性和性能的两篇文章并添加了本人在使用过程中遇到的一些问题和经验总结。browserless 的两篇原文链接在最后。

不要使用无头浏览器

Headless Chrome 占用大量的资源。无论如何,只要可以的话,不要运行无头浏览器。特别是千万别在你跑其他应用的服务器上跑。无头浏览器的行为难以预测,对资源占用非常多,就像是 Rick and Morty 里面的 Meseeks(美国动画片《瑞克和莫蒂》中,召唤出了过多的 Meseeks 导致出了大问题)。几乎所有你想通过浏览器用的事情(比如说运行 JavaScript)都可以使用简单的 Linux 工具来实现。Cheerio 和其他的库提供了优雅的 Node API 来实现 HTTP 请求和采集等需求。

比如,你可以像这样获取一个页面并抽取内容:

import cheerio from 'cheerio';
import fetch from 'node-fetch';
 
async function getPrice(url) {
    const res = await fetch(url);
    const html = await res.test();
    const $ = cheerio.load(html);
    return $('buy-now.price').text();
}
 
getPrice('https://my-cool-website.com/');

显然这肯定不能覆盖所有的方面,如果你正在读这篇文章的话,你可能需要一个无头浏览器,所以接着看吧。

使用 docker 来管理 Chrome

在 Linux 上跑 Chrome 的话,很可能连字体渲染都没有,还要安装好多的依赖。Chrome 除了浏览之外,还会有好多的莫名其妙的线程,所以最好使用 docker 来管理。建议使用 browserless/chrome 这个镜像,这个镜像是 browserless 这家专门做 Chrome 渲染的公司在生产环境中使用的镜像。关于这个镜像的文档在这里:https://docs.browserless.io/docs/docker.html (英文)

docker run -p 8080:3000 --restart always -d --name browserless browserless/chrome
const puppeteer = require('puppeteer');
 
    // 从 puppeteer.launch() 改成如下
    const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:3000' });
    const page = await browser.newPage();
 
    await page.goto('http://www.example.com/');
    const screenshot = await page.screenshot();
 
    await browser.disconnect();

保持 Chrome 在运行状态

当负载很高的情况下,Chrome 启动可能会花上好几秒钟。对大多数情况来说,我们还是希望避免这个启动时间。所以,最好的办法就是预先启动好 Chrome,然后让他在后台等着我们调用。

如果使用 browserless/chrome 这个镜像的话,直接指定 PREBOOT_CHROME=true 就好了。下面的命令会直接启动 10 个浏览器,如果你指定 KEEP_ALIVE,那么在你断开链接(pp.disconnect)的时候也不会关闭浏览器,而只是把相关页面关闭掉。

docker run -d -p 3000:3000 \
    -e DEBUG=browserless* \
    -e PREBOOT_CHROME=true -e MAX_CONCURRENT_SESSIONS=10 -e KEEP_ALIVE=true
    --name browserless browserless/chrome:latest

page.evaluate 是你的好朋友

Puppeteer 有一些很酷的语法糖,比如可以保存 DOM 选择器等等东西到 Node 运行时中。尽管这很方便,但是当有脚本在变换 DOM 节点的时候很可能坑你一把。尽管看起来有一些 hacky,但是最好还是在浏览器中运行浏览器这边的工作。也就是说使用 page.evaluate 来操作。

比如,不要使用下面这种方法(使用了三个 async 动作):

const $anchor = await page.$('a.buy-now');
const link = await $anchor.getProperty('href');
await $anchor.click();
 
return link;

这样做,使用了一个 async 动作:

await page.evaluate(() => {
    const $anchor = document.querySelector('a.buy-now');
    const text = $anchor.href;
    $anchor.click();
});

另外的好处是这样做是可移植的:也就是说你可以在浏览器中运行这个代码来测试下是不是需要重写你的 node 代码。当然,能用调试器调试的时候还是用调试器来缩短开发时间。

最重要的规则就是数一下你使用的 await 的数量,如果超过 1 了,那么说明你最好把代码写在 page.evaluate 中。原因在于,所有的 async 函数都必须在 Node 和 浏览器直接传来传去,也就是需要不停地 json 序列化和反序列化。尽管这些解析成本也不是很高(有 WebSocket 支持),但是总还是要花费时间的。

除此之外,还要牢记使用 puppeteer 的时候是由两个 JS 的执行环境的,别把他们搞混了。在执行 page.evaluate 的时候,函数会先被序列化成字符串,传递给浏览器的 JS 运行时,然后再执行。比如说下面这个错误。

const anchor = 'a';
 
await page.goto('https://example.com/');
 
// 这里是错的,因为浏览器中访问不到 anchor 这个变量
const clicked = await page.evaluate(() => document.querySelector(anchor).click());

修改方法也很简单,把这个参数作为变量传递给 page.evaluate 就可以了。

const anchor = 'a';
 
await page.goto('https://example.com/');
 
// Here we add a `selector` arg and pass in the reference in `evaluate`
const clicked = await page.evaluate((selector) => document.querySelector(selector).click(), anchor);

队列和限制并发

browserless 的镜像一个核心功能是无缝限制并行和使用队列。也就是说消费程序可以直接使用 puppeteer.connect 而不需要自己实现一个队列。这避免了大量的问题,大部分是太多的 Chrome 实例杀掉了你的应用的可用资源。

$ docker run -e "MAX_CONCURRENT_SESSIONS=10" browserless/chrome

上面限制了并发连接数到10,还可以使用MAX_QUEUE_LENGTH来配置队列的长度。总体来说,每1GB内存可以并行运行10个请求。CPU 有时候会占用过多,但是总的来说瓶颈还是在内存上。

不要忘记 page.waitForNavigation

如果点击了链接之后,需要使用 page.waitForNavigation 来等待页面加载。

下面这个不行

await page.goto('https://example.com');
await page.click('a');
const title = await page.title();
console.log(title);

这个可以

await page.goto('https://example.com');
page.click('a');
await page.waitForNavigation();
const title = await page.title();
console.log(title);

屏蔽广告内容

browserless 家的镜像还有一个功能就是提供了屏蔽广告的功能。屏蔽广告可以是你的流量降低,同时提升加载速度。

只需要在连接的时候加上 blockAds 参数就可以了。

启动的时候指定 –user-data-dir

Chrome 最好的一点就是它支持你指定一个用户的数据文件夹。通过指定用户数据文件夹,每次打开的时候都可以使用上次的缓存。这样可以大大加快网站的访问速度。

const browser = await pp.launch({
    args: ["--user-data-dir=/var/data/session-xxx"]
})

不过需要注意的是,这样的话会保存上次访问时候的 cookie,这个不一定是你想要的效果。

构建最小版本的 Chrome

构建最小版本的 Chromium

为什么需要 Chrome 浏览器渲染

  1. 动态 ajax 页面
  2. 页面编码异常或者结构过甚,lxml 无法解析

dirty page examples:

  1. 页面的 style 在 html 外面,并且有黏贴的 Word 文档。http://gzg2b.gzfinance.gov.cn/gzgpimp/portalsys/portal.do?method=pubinfoView&&info_id=-2316ce5816ab90783eb-720f&&porid=gsgg&t_k=null
  2. 有好多个 html 标签,并且编码不一致。http://www.be-bidding.com/gjdq/jingneng/show_zbdetail.jsp?projectcode=1180903010&flag=3&moreinfo=true

优化方案

  1. 不加载图片和视频,但是保留占位
  2. 使用 proxy api 更改代理
  3. 禁用 H5 相关 API
  4. 删除 ICU 相关

参考文献

  1. https://peter.sh/experiments/chromium-command-line-switches/
  2. https://joydig.com/port-chromium-to-embedded-linux/
  3. Android 上的 Chrome 裁剪,值得借鉴。https://blog.csdn.net/mogoweb/article/details/76653627
  4. 架构图 https://blog.csdn.net/mogoweb/article/details/76653627
  5. webkit 架构图 https://blog.csdn.net/a957666743/article/details/79702895
  6. Chrome proxy API https://developer.chrome.com/extensions/proxy
  7. Chrome 嵌入式裁剪,直击底层 https://joydig.com/category/chromium/
  8. 官方构建教程 https://chromium.googlesource.com/chromium/src/+/master/docs/linux_build_instructions.md
  9. 编译选项https://blog.csdn.net/wanwuguicang/article/details/79751503

爬虫利器 Chrome Headless 和 Puppeteer 最佳实践

翻译自:https://docs.browserless.io/blog/2018/06/04/puppeteer-best-practices.html

browserless 已经运行了200万次的 chrome headless 请求,下面是他们总结出来的最佳实践:

一、不要使用无头浏览器

无头 Chrome 占用的大量资源

无论如何,只要可以的话,不要运行无头浏览器。特别是千万别在你跑其他应用的服务器上跑。无头浏览器的行为难以预测,对资源占用非常多,就像是 Rick and Morty 里面的 Meseeks(美国动画片《瑞克和莫蒂》中,召唤出了过多的 Meseeks 导致出了大问题)。几乎所有你想通过浏览器用的事情(比如说运行 JavaScript)都可以使用简单的 Linux 工具来实现。Cheerio 和其他的库提供了优雅的 Node API 来实现 HTTP 请求和采集等需求。

比如,你可以像这样获取一个页面并抽取内容:

import cheerio from "cheerio";
import fetch from "node-fetch";

async function getPrice(url) {
    const res = await fetch(url);
    const html = await res.test();
    const $ = cheerio.load(html);
    return $("buy-now.price").text();
}

getPrice("https://my-cool-website.com/");

显然这肯定不能覆盖所有的方面,如果你正在读这篇文章的话,你可能需要一个无头浏览器,所以接着看吧。

二、不要在不需要的时候运行无头浏览器

我们遇到过好多客户尝试在不使用的时候也保持浏览器开着,这样他们就总能够直接连上浏览器。尽管这样能够有效地加快连接速度,但是最终会在几个小时内变糟。很大程度上是因为浏览器总会尝试缓存并且慢慢地吃掉内存。只要你不是在活跃地使用浏览器,就关掉它。

import puppeteer from "puppeteer";

async function run() {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();

    await page.goto("https://www.example.com/");

    // More stuff ...page.click() page.type()

    browser.close(); // <- Always do this!
}

在 browserless,我们会给每个会话设置一个定时器,而且在WebSocket链接关闭的时候关闭浏览器。但是如果你使用自己独立的浏览器的话,记得一定要关闭浏览器,否则你很可能在半夜还要陷入恶心的调试中。

三、 page.evaluate 是你的好朋友

Puppeteer 有一些很酷的语法糖,比如可以保存 DOM 选择器等等东西到 Node 运行时中。尽管这很方便,但是当有脚本在变换 DOM 节点的时候很可能坑你一把。尽管看起来有一些 hacky,但是最好还是在浏览器中运行浏览器这边的工作。也就是说使用 page.evaluate 来操作。

比如,不要使用下面这种方法(使用了三个 async 动作):

const $anchor = await page.$("a.buy-now");
const link = await $anchor.getProperty("href");
await $anchor.click();

return link;

这样做,使用了一个 async 动作:

await page.evaluate(() => {
    const $anchor = document.querySelector("a.buy-now");
    const text = $anchor.href;
    $anchor.click();
});

另外的好处是这样做是可移植的:也就是说你可以在浏览器中运行这个代码来测试下是不是需要重写你的 node 代码。当然,能用调试器调试的时候还是用调试器来缩短开发时间。

最重要的规则就是数一下你使用的 await 的数量,如果超过 1 了,那么说明你最好把代码写在 page.evaluate 中。原因在于,所有的 async 函数都必须在 Node 和 浏览器直接传来传去,也就是需要不停地 json 序列化和反序列化。尽管这些解析成本也不是很高(有 WebSocket 支持),但是总还是要花费时间的。

四、并行化浏览器,而不是页面

上面我们已经说过尽量不要使用浏览器,而且只在需要的时候才打开浏览器,下面的这条最佳实践是——在一个浏览器中只使用一个会话。尽管通过页面来并行化可能会给你省下一些时间,如果一个页面崩溃了,可能会把整个浏览器都带翻车。而且,每个页面都不能保证是完全干净的(cookies 和存储可能会互相渗透)。

不要这样:

import puppeteer from "puppeteer";

// Launch one browser and capture the promise
const launch = puppeteer.launch();

const runJob = async (url) {
    // Re-use the browser here
    const browser = await launch;
    const page = await browser.newPage();
    await page.goto(url);
    const title = await page.title();

    browser.close();

    return title;
};

要这样:

import puppeteer from "puppeteer";

const runJob = async (url) {
    // Launch a clean browser for every "job"
    const browser = puppeteer.launch();
    const page = await browser.newPage();
    await page.goto(url);
    const title = await page.title();

    browser.close();

    return title;
};

每一个新的浏览器实例都会得到一个干净的 --user-data-dir (除非你手工设定)。也就是说会是一个完全新的会话。如果 Chrome 崩溃了,也不会把其他的会话一起干掉。

五、队列和限制并发

browserless 的一个核心功能是无缝限制并行和使用队列。也就是说消费程序可以直接使用 puppeteer.connect 而不需要自己实现一个队列。这避免了大量的问题,大部分是太多的 Chrome 实例杀掉了你的应用的可用资源。

最好也最简单的方法是使用 browserless 提供的镜像:

# Pull in Puppeteer@1.4.0 support
$ docker pull browserless/chrome:release-puppeteer-1.4.0
$ docker run -e "MAX_CONCURRENT_SESSIONS=10" browserless/chrome:release-puppeteer-1.4.0

上面限制了并发连接数到10,还可以使用MAX_QUEUE_LENGTH来配置队列的长度。总体来说,每1GB内存可以并行运行10个请求。CPU 有时候会占用过多,但是总的来说瓶颈还是在内存上。

六、不要忘记 page.waitForNavigation

如果点击了链接之后,需要使用 page.waitForNavigation 来等待页面加载。

下面这个不行

await page.goto("https://example.com");
await page.click("a");
const title = await page.title();
console.log(title);

这个可以

await page.goto("https://example.com");
page.click("a");
await page.waitForNavigation();
const title = await page.title();
console.log(title);

七、使用 docker 来管理 Chrome

Chrome 除了浏览之外,还会有好多的莫名其妙的线程,所以最好使用 docker 来管理

爬虫如何尽量模拟浏览器

http headers

 
发送http请求时,Host, Connection, Accept, User-Agent, Referer, Accept-Encoding, Accept-Language这七个头必须添加,因为正常的浏览器都会有这7个头。
 
其中:

  1. Host一般各种库都已经填充了
  2. Connection填Keep-Alive
  3. Accept一般填text/html 或者application/json
  4. User-Agent使用自己的爬虫或者伪造浏览器的UA
  5. Referer一般填当前URL即可,考虑按照真是访问顺序添加referer,初始的referer可以使用google。
  6. Accept-Encoding 从gzip和deflate中选,好多网站会强行返回gzip的结果
  7. Aceept-Language根据情况选择,比如zh-CN, en-US

cookies

cookie是需要更新的
 

others

 
可能有一些人类不可见的陷阱链接,不要访问这些链接

爬取间隔自适应

就是已经限制了你这个IP的抓取,就不要傻傻重复试,怎么也得休息一会。网易云音乐操作起来比较简单,sleep一下就好了。其实sleep的间隔应该按情况累加,比如第一次sleep 10秒,发现还是被约束。那么久sleep 20秒… 这个间隔的设置已经自适应的最终效果是经验值。

ref

  1. http://www.cnblogs.com/jexus/p/5471665.html