爬虫

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',
          ANDROID: '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

为什么不使用 scrapy,而是从头编写爬虫系统?

时隔一年了,来回答下自己提的问题。个人不喜欢 scrapy 原因一言以蔽之:高不成,低不就,弊大于利
总的来说,需要使用代码来爬一些数据的大概分为两类人:

  1. 非程序员,需要爬一些数据来做毕业设计、市场调研等等,他们可能连 Python 都不是很熟;
  2. 程序员,需要设计大规模、分布式、高稳定性的爬虫系统,对他们来说,语言都无所谓的,更别说用不用框架了。

为什么不适合初学者?

对于初学者来说用不上 scrapy 的原因很简单:

  1. scrapy 太复杂了;
  2. scrapy 采用异步模式带来的高性能和在反爬面前实际上没有任何卵用;
  3. scrapy 项目冗余的代码结构对初学者完全是过度设计。

对于一个任何一个已经入门的程序员来说,Python 都算不上一个很复杂的语言,除了不用大括号可能让一些人感觉有些不适应之外,基本上看看语法上手就能写了。但是恰恰是因为我们都是老司机了,所以不能体会到使用一门编程语言对于外行来说可能『比登天还难』。如果不用 scrapy,可能我只需要这样:

# 以下代码未经测试,可能有些许 bug
import requests

def main():
    for i in range(100):
        rsp = requests.get(f"http://www.example.com/{i}.html")
        with open("example-{i}.html", "w") as f:
            print(f"saving {i}")
            f.write(rsp.text)

if __name__ == "__main__":
    main()

就写好了一个简单的爬虫。而使用 scrapy 呢,大概需要这样吧:

# 以下代码未经测试,可能有些许 bug
import scrapy

class QuotesSpider(scrapy.Spider):
    name = 'quotes'

    def start_requests(self):
        for i in range(100):
            yield scrapy.Request(url=f"http://www.example.com/{i}.html", callback=self.parse)

    def parse(self, response):
        page = response.url.split('/')[-2]
        with open('example-%s.html' % page, 'wb') as f:
            f.write(response.body)
        self.log('Save file %s' % page)

先不说代码增长了不少,初学者会问到这些问题:“什么是 class?为什么类还有参数?啊,什么是继承?yield 又是什么鬼,那个 scrapy.Request 又是啥?”这些都是心智负担。那么 scrapy 这些心智负担又给我们带来了什么好处呢?好处是性能和相对来说比较统一的代码结构,但是其实这两个对初学者并没有什么卵用啊……

scrapy 采用了 twisted 作为基础,实现了基于协程的高并发。协程看着虽然挺好,但是对于非程序员来说,他们往往就想对一个站点做定向爬取,你说你蹭蹭蹭把并发涨上去了,无非两个后果:

  1. 对方承受不住你爬,挂掉了,你拿不到数据;
  2. 对方把你封禁了,疯狂弹验证码,你拿不到数据。

所以,对于非程序员做的一些定向爬取来说,速度是没有意义的,甚至往往是越慢越好。scrapy out。

那么相对来说比较统一的代码结构有什么卵用吗?答案依然是没有。我们知道在 web 开发领域基本上稍微有点规模的项目还是要使用框架的,哪怕是 flask 这种微框架。在 web 开发领域,有经典的 MVC 模式,我们需要 路由、模板、ORM 这些固定的组件,所以主循环是由框架和 web server 来控制的。而对于爬虫呢?其实没有什么固定的模式,scrapy 也仅仅是定义了几个钩子函数而已,反倒我们没有了主循环,在编写一些特定逻辑的时候非常受到掣肘。

另外 scrapy 提供的一些其他功能,比如说抓取的队列或者去重等等,个人感觉有过度封装的味道,而且也都是在内存里,在反爬导致爬虫挂掉这种故障面前没有什么卵用,不二次开发的话还是得重爬。对于小白来说,也不用想 redis 这些幺蛾子,其实可以用 Google 最开始使用的一个很简单的方法,就把每个新抓到的 url 写到一个 txt 文件就好了,爬虫每次重启的时候首先读取这个 txt 就好了,网上乱七八糟的教程大多是炫技的。

为什么不适合大型爬虫系统?

前面说到,scrapy 基于 twisted。twisted 是 Python 的一个异步框架,最大的问题就是太难懂了,而且现在官方应支持了 asyncio,所以 twisted 的未来堪忧,甚至比起 twisted 来说,我更愿意投入时间到 curio 这样新兴的有潜力的异步框架。第二点就是 scrapy 控制了主循环,所以二次开发相当于只能在他的框架内做一些修修补补,并且还要兼容 twisted。

既然要开发大型爬虫系统,那么其中很重要的一部分就是爬虫的调度了。一种比较简单的模式是 scheduler 作为 master,全局调度。另一种模式没有 master,所有的爬虫 worker 都是对等的。在实际生产中显然是第一种用的更多。

显然 scheduler 这部分是不能再用一个爬虫框架来实现的,连主循环都没有怎么写逻辑呢?我们可能还要实现增量爬取,或者消费业务方发来的爬取请求等各种业务,这块显然是在 scheduler 里面的,那么这个爬虫系统无非是 scheduler 分发任务给各个 worker 来抓取。worker 还可以使用 scrapy 实现,但是呢,这个 worker 其实已经弱化为一层薄薄的 downloader 了,那我要他干嘛呢?scrapy 的核心逻辑也不过是个深度或者广度优先的遍历而已,少一个依赖不好么……

总结一下,爬虫的工作量要么在反爬,要么在调度等业务逻辑,本身只是一个 requests.get 而已,scrapy 提供的种种抽象对于初学者太复杂,大型系统又用不上,所以个人不推荐使用包括但不限于 scrapy 在内的所有爬虫框架

建议所有认为学习框架会使自己变强的人读读:Stop learning frameworks 和 评论,中文翻译

以上仅代表个人观点,欢迎讨论,不要人身攻击。

LeetCode 1236/1242 设计一个(多线程)爬虫解法

单线程题目 LeetCode-1236

具体题目就不说了,直接去 LeetCode 上看就好了。1236 要求使用单线程即可,考察的主要是图的遍历。只要注意到对于新发现的节点需要考虑是否已经访问过就好了。在实际生产中,肯定也是要用广度优先,深度优先基本就会陷进一个网站出不来了。

from urllib.parse import urlsplit

class Solution:
    def crawl(self, startUrl: str, htmlParser: 'HtmlParser') -> List[str]:
        domain = urlsplit(startUrl).netloc
        q = [startUrl]
        visited = set([startUrl])
        while q:
            newUrls = []
            for url in q:
                urls = htmlParser.getUrls(url)
                for newUrl in urls:
                    u = urlsplit(newUrl)
                    if u.netloc != domain:
                        continue
                    if newUrl in visited:
                        continue
                    visited.add(newUrl)
                    newUrls.append(newUrl)
            q = newUrls
        return list(visited)

多线程题目 LeetCode-1242

1242 题要求使用多线程来实现。在现实生活中,爬虫作为一个 IO 密集型的任务,使用多线程是一项必须的优化。

在上述的单线程版本中,我们使用了 visited 这个数组来存放已经访问过的节点,如果我们采用多线程的话,并且在每个线程中并发判断某个 URL 是否被访问过,那么势必需要给这个变量加一个锁。而我们知道,在多线程程序中,加锁往往造成性能损失最大,最容易引起潜在的 bug。那么有没有一种办法可以不用显式加锁呢?

其实也很简单,我们只要把需要把并发访问的部分放到一个线程里就好了。这个想法是最近阅读 The Go Programming Language 得到的启发。全部代码如下:

import threading
import queue
from urllib.parse import urlsplit

class Solution:
    def crawl(self, startUrl: str, htmlParser: 'HtmlParser') -> List[str]:
        domain = urlsplit(startUrl).netloc
        requestQueue = queue.Queue()
        resultQueue = queue.Queue()
        requestQueue.put(startUrl)
        for _ in range(5):
            t = threading.Thread(target=self._crawl, 
                args=(domain, htmlParser, requestQueue, resultQueue))
            t.daemon = True
            t.start()
        running = 1
        visited = set([startUrl])
        while running > 0:
            urls = resultQueue.get()
            for url in urls:
                if url in visited:
                    continue
                visited.add(url)
                requestQueue.put(url)
                running += 1
            running -= 1
        return list(visited)

    def _crawl(self, domain, htmlParser, requestQueue, resultQueue):
        while True:
            url = requestQueue.get()
            urls = htmlParser.getUrls(url)
            newUrls = []
            for url in urls:
                u = urlsplit(url)
                if u.netloc == domain:
                    newUrls.append(url)
            resultQueue.put(newUrls)

在上面的代码中,我们开启了 5 个线程并发请求,每个 worker 线程都做同样的事情:

  1. 从 requestQueue 中读取一个待访问的 url;
  2. 执行一个很耗时的网络请求:htmlParser.getUrls
  3. 然后把获取到的新的 url 处理后放到 resultQueue 中。

而在主线程中:

  1. 从 resultQueue 中读取一个访问的结果
  2. 判断每个 URL 是否已经被访问过
  3. 并分发到 requestQueue 中。

我们可以看到在上述的过程中并没有显式使用锁(当然 queue 本身是带锁的)。原因就在于,我们把对于需要并发访问的结构限制在了一个线程中。

当然如果可以用锁的话,也可以在每个 worker 线程中计数。而这种情况下,为了使用 running > 0 这个条件,一定要首先在发现新的 url 的时候 running++,在处理完整个页面之后再 running–。

手机如何使用 Charles 抓 https 包

问题描述

安装 Charles 之后,使用手机自带浏览器访问 http://chls.pro/ssl ,下载到了 getssl.crt 文件,点击安装之后提示“未找到可安装的证书”

解决方法

首先下载 Chrome 浏览器,然后再访问 http://chls.pro/ssl ,安装下载到的证书就好了。

通过浏览器访问 https://www.baidu.com ,Charles 中能够抓到包的内容,说明安装成功了

参考

  1. https://segmentfault.com/a/1190000011573699
  2. https://testerhome.com/topics/9445

使用 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,这个不一定是你想要的效果。