1. 引言

许多爬虫初学者在接触到无头浏览器的时候都会有一种如获至宝的感觉,仿佛看到了爬虫的终极解决方案。无论是所有爬虫教程中都会出现的PhantomJS、Selenium,亦或是相对冷门的Nightmare,到后来居上的Puppeteer,都能够作为爬虫工程师的利刃,撕开反爬的一道道屏障。无头浏览器难道就是爬虫的终点了吗?那必然不是,否则各位爬虫工程师就只值3000块一个月了。

首先,无论多强大多轻便的无头浏览器,在同等配置的机器上,并发永远不可能高过python的一行request请求。在大规模数据采集中,服务器成本是必须考虑的问题,采集同样规模的数据,人家服务器成本花了1万块,你给霍霍了十几万,你猜老板会不会问候你老豆。其次,用无头浏览器写过爬虫的人应该都会觉得,很难靠headless browser搞出来一个复杂的、长期稳定的、可靠的大型爬虫,它们更适合应用在一些小规模的数据采集场合。最后,也是最重要的,无头浏览器并不是无敌的,反爬的一方不会乖乖束手就擒,你有张良计,他自然就有过强梯,反爬一方会通过某些方法检测出无头浏览器,然后把这些请求全部处理掉,某些网站你使用无头浏览器甚至无法打开首页。

上段说的最后一点,也就是针对无头浏览器的反爬攻防,就是本文所要讨论的内容。PhantomJS和Selenium已经日薄西山,本文只研究后来居上的Puppeteer。

2. 从蛛丝马迹中认出Puppeteer

2.1 webdriver

介绍

webdriver可以说是Puppeteer最明显的一个特征,检测也非常简单,获取navigator.webdriver这一属性,在默认启动的Puppeteer中,它的值为true,而在正常浏览器中,navigator里是没有这一属性的,是undefined。

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'webdriver', {
    get: () => false,
  });
});

简单解释一下这段代码,在新建页面之前,将webdriver的get方法强制返回false。那么类似于if (navigator.webdriver)这样的检测就不会生效了。

var attr = window.navigator, result = [];
do {
    Object.getOwnPropertyNames(attr).forEach(function(a) {
        result.push(a)
    })
} while (attr=Object.getPrototypeOf(attr));

这段代码中,获取了navigator中所有属性名,而非属性值,也就是说,即便你把webdriver的值改为false了,这个属性仍然是在的。但是,在正常使用的chrome中,navigator是没有这一属性的,一旦检测到webdriver这个属性名,大概率可以判定为puppeteer。

破盾

破盾就不能针对puppeteer下手了,反正我是没有办法在检测前delete掉navigator.webdriver这个属性。
在发现这段盾的代码后,给它后面注入一点:

result = result.filter(function(item) {
    return item != "webdriver"
});

嗯,从根源入手解决了问题。

2.2 UserAgent

介绍

UA在反爬界的地位,相当于hello world在编程界的地位,入门第一课,就会教UA的检测。所以再垃圾的爬虫,也知道给自己伪造一个UA,puppeteer的UA也是如此。只要对puppeteer反爬稍有研究,就会知道,默认情况下,puppeteer的UA有HeadlessChrome这一关键词,非常容易检测。

这个矛简单的我都不想写,一行代码搞定。

await page.setUserAgent("随便写个UA");

从上面可以看到,修改UA一行代码就搞定了,而且没什么门槛,可以说人人都知道这个事情,所以这里要检测,肯定不能检测HeadlessChrome关键词这么简单。

我在windows和linux下的puppeteer分别获取了一些属性:

  • windows中的navigator.userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3477.0 Safari/537.36
  • windows中的navigator.platform: “Win32”
  • linux中的navigator.userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3477.0 Safari/537.36
  • linux中的navigator.platform: “Linux x86_64”
    由于手动设置了UA,所以在windows和linux下的UA是一致的,都是我设的值,但在platform这一属性漏出了马脚。UA中明明写着windows,platform却说你是linux,这岂不是自相矛盾?一经发现,肯定是要把这个请求打入冷宫的。

我相信大多数程序员都会选择把爬虫部署在linux服务器上,windows服务器真是谁用谁知道。。。这里就不吐槽它了。而且根据我实践经验,puppeteer在linux上运行的远比在windows上稳定。根据上述两点,得出结论,对比检查UA和platform,能取得一些效果。

破盾

举一隅不以三隅反,则不复也。

2.3 plugins

介绍

对plugins比较官方的描述是:返回一个 PluginArray 类型的对象, 包含了当前所使用的浏览器安装的所有插件。获取方法是navigator.plugins。这个属性在有头的chrome中,会返回一堆叫做PluginArray的东西,但在无头浏览器中,它是空的,这个属性的没有值的。PluginArray是有length属性的,所以可以获取navigator.plugins.length的值,如果是0,则基本上是无头的。

没有条件,就创造条件,没有值,就赋值:

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'plugins', {
    get: () => [1, 2, 3],
  });
});

不是不让空么,那就丢个数组进去咯。

矛中所用的赋值[1, 2, 3]的方法是网上比较常见的赋值plugins的方法,不是因为大家偷懒,实在是plugins忒难构造了。在介绍中说了,这是一个PluginArray对象,并非Array对象。可以打开浏览器看一下,这个属性的值是不是挺复杂的。复杂那对于盾来说就是好事,不是难构造么,那就不检查length了,仔细看看plugins里头是些啥:

for (var result = [], n = 0; n < navigator.plugins.length; n++) !
function(n) {
    var t = navigator.plugins[n],
    r = [t.name, t.description, t.filename, t.version].join("::"),
    o = [];
    Object.keys(t).forEach(function(e) {
        o.push([t[e].type, t[e].suffixes, t[e].description].join("~"))
    }),
    o = o.join(","),
    result.push(r + "__" + o)
} (n);

通过上面的代码获取plugins中的具体内容,得到了如下result:

result = [
    "Chrome PDF Plugin::Portable Document Format::internal-pdf-viewer::"
    +"__application/x-google-chrome-pdf~pdf~Portable Document Format", 
    "Chrome PDF Viewer::::mhjfbmdgcfjbbpaeojofohoefgiehjai::__application/pdf~pdf~", 
    "Native Client::::internal-nacl-plugin::__application/x-nacl~~Native Client Executable,"
    +"application/x-pnacl~~Portable Native Client Executable"
]

如果是一个类似于[1, 2, 3]的数组的话,只能得到这样的结果:

result = [
    "::::::__", 
    "::::::__", 
    "::::::__"
]

就只剩下一些分隔符了,什么信息也没有。

破盾

喜欢检测的细是吧,那就大家死磕到底:

await page.evaluateOnNewDocument(() => {
  Object.defineProperty(navigator, 'plugins', {
    get: () => [
        {
            0: {type: "application/x-google-chrome-pdf", suffixes: "pdf", description: "Portable Document Format", enabledPlugin: Plugin},
            description: "Portable Document Format",
            filename: "internal-pdf-viewer",
            length: 1,
            name: "Chrome PDF Plugin"
        },
        {
            0: {type: "application/pdf", suffixes: "pdf", description: "", enabledPlugin: Plugin},
            description: "",
            filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai",
            length: 1,
            name: "Chrome PDF Viewer"
        },
        {
            0: {type: "application/x-nacl", suffixes: "", description: "Native Client Executable", enabledPlugin: Plugin},
            1: {type: "application/x-pnacl", suffixes: "", description: "Portable Native Client Executable", enabledPlugin: Plugin},
            description: "",
            filename: "internal-nacl-plugin",
            length: 2,
            name: "Native Client"
        }
    ],
  });
});

乍一看很像了,但是跑盾的代码result里会出现一串小尾巴。这就涉及到PluginArray非常恶心的一个特性了,暂时按下不提。

2.4 window.chrome

从这条开始,就是写不太重要、特征没那么明显的属性了。window.chrome,在控制台输入chrome,敲个回车,就取到值了,有头有值,无头无值,这样检测就行了:

function hasChrome() {
    return !! window.chrome
}

绕过检测也简单,就这样大差不差了,window.chrome的详细信息也很难像plugins那样拿来具体对比。

await page.evaluateOnNewDocument(() => {
  window.chrome = {
    runtime: {},
    loadTimes: function() {},
    csi: function() {},
    app: {}
  };
});

2.5 一些废弃参数

puppeteer是由谷歌的Chrome团队在维护,战斗力强悍,版本更新也很快。随着版本的更新,以前一些可以用来检测puppeteer的特征现在已经不存在了。但是也写下来介绍一下,或许有助于开拓思路。

Language

这一属性取自于navigator.language,在早期的puppeteer版本中,无头模式下是没有这个属性的,所以可以通过这种方法来检测:

function hasChrome() {
    return !navigator.language || !navigator.languages
}

这种检测的绕过方法经过前面的长篇累牍,相信已经不需要赘述了。

Viewport

同样是早期版本中,puppeteer打开的无头浏览器会有一个默认的窗口大小,800*600。

大家不妨看一下800*600的窗口有多小,正常用户是不可能用这个窗口尺寸浏览网页的,但也不能武断的拦截这样的请求,林子大了什么奇葩用户没有。所以这一参数可以进行收集,如果发现大量出现这个窗口尺寸的请求,就可以考虑采取反爬措施了。

Notification.permission

之前看过文章提到检测这个参数的,我不是很认同,所以就提一下有这么回事儿,有兴趣的自己去看吧。

3. 盾上加盾

看到这里,可能会觉得似乎任何一种检测都有办法绕过。这么说也没错,因为javascript就是有这么个毛病,你放在浏览器前端执行,用户就一定能看到你的代码,毫无隐私可言。就如2.1的破盾部分,直接通过js注入修改了浏览器的特征属性,那么检测方法再怎么精妙,也无法逃过矛的攻击。

所以在浏览器上,无论是加密、反爬,还是puppeteer检测,最重要的还是对js代码的混淆,就像著名反爬服务提供商某数做的那样,混淆到你没法读、没法调试、没法手动运行,那样才能把盾铸造的更加坚固。当然,也不可能牢不可破,破解某数的人也不在少数了。

4. 更高级的检测方法

浏览器指纹

通过收集详细的参数,让你可以在后台把用户的浏览器扒个干净,非常值得探索的一个领域,接下来会找时间写篇文章专门介绍浏览器指纹。