crawler

爬虫数据存储的一些选择

爬虫是一种典型的「读少写多」的应用场景。而且爬虫产生的数据一般是作为离线数据,而不是在线数据。
也就是说这些数据主要是用于离线数据分析,而不是直接供线上用户查询使用。
即使线上需要使用爬虫数据,也会需要经过清洗处理过后再放到在线库。总之,不会直接供线上直接使用。

本文总结了一些经验和踩过坑,希望能对读者有帮助。

写入通用原则

  1. 要批量写,不要挨个写
  2. 要顺序写,不要随机写

如果我们开了一个多线程的爬虫,然后每个线程每爬到一条数据就调一下 db.insert(item) 插入数据,数据一多就是灾难性的。

首先,每个线程持有一个数据库链接对数据库的负载就产生了不小压力。其次,每条数据都去调用数据库,那么每次插入时间都得
加上数据库的往返时间,也就是 2RTT(round trip time)。再者,每次插入的都是不同的数据,可能在磁盘的不同位置,导致
磁盘的写入时间大部分都花在寻道上了,磁盘 IO 时间会大幅提升。最后,如果是 SQL 型的数据库,默认配置下,
可能还会有事务的影响。

正确的做法是——用队列。不同的线程都把数据先发到一个队列中,不管是你用的语言自带的内存里的 Queue,还是 redis list,
或者是 kafka,都可以。然后由另一个线程或者脚本读取这个队列,把数据整理之后,定期或者定量写入数据库。

这样做基本上解决了上面提到的每个问题。只有存储线程持有数据库链接,每一条数据不会在需要 2RTT,摊薄下来可能是 2RTT / 1000,
数据经过整理后,每次可以都插入统一中类型的数据,磁盘不需要总是在寻道。

当然,这种方案也会引入一些新的问题,需要注意解决:

  1. 处理流程变成了异步,不能实时在数据中看到最新的爬取结果
  2. 多了消息队列的环节,多了丢数据的可能
  3. 如果消息队列是内存性的,不要让消息队列爆了

以上这些问题都是使用 MQ 的常见问题了,这里不再展开。

数据库选型

大概有这些选择:

  1. CSV、JSON、SQLite 或者其他单机文件
  2. SQL 数据库
  3. MongoDB 等文档数据库
  4. HBase 等 Hadoop 生态圈存储
  5. S3 类型对象存储
  6. Kafka 等持久性消息队列

如果你只是简单地「单线程」爬几页数据分析用,那么存个 CSV 或者 JSON 就可以了。如果你开始上多线程,甚至多机了,
既要考虑写入的时候加锁,而且没法分布式写入,单文件存储就不太合适了。

规模再大一点可以考虑 MySQL。虽然 MySQL 是一个 OLTP 数据库,通常意义上来说更适用于线上数据库。但是对于数据量不大的爬虫来说,
比如说总数据量不会超过 100GiB,也已经足够用了。而且 MySQL 可以添加 unique 索引,一定程度上还能帮助解决数据去重的问题。
这里有几点需要注意:

  1. 使用 ORM 创建的表中包含了不少外键约束之类的东西,对于爬来的数据,中间插入的时候可能还不满足这个外键,最好把这个约束删除掉
  2. 2.

有些人喜欢用 MongoDB 来存储爬虫数据,他们给出的理由也很有吸引力——爬虫数据多是半结构化的,而且数据结构可能经常跟着源网站变,
用 MongoDB 这种 schemaless 的文档数据库再合适不过了。然而我个人非常不推荐用 MongoDB,原因如下:

  1. 我觉得定义好表结构不是一个缺点,反而是一个优点,这样能够在开发调试阶段就发现各种异常情况,保证程序稳定
  2. MySQL 的图形化客户端太多了,比如说 Navicat,Sequel Pro 等等。对于小公司来说,这个客户端就已经够用了,根本不需要开发什么单独的管理后台
    相反,MongoDB 基本没有什么特别好用的客户端
  3. MySQL 和 Postgres 也早就支持了 JSON 字段,实在不是特别规整的数据,存在 JSON 字段就行了
  4. 数据分析的第三方库,比如 pandas,对 SQL 的支持都是原生的,一个 read_sql 就把数据读出来了

数据再多一些,或者并发量再大一些的话,可能单独使用 MySQL 就不合适了。这时候你可以对 MySQL 做定期归档,比如说把添加时间在一个月以上的数据
都按日期写入到 Hive 或者 S3 中,然后删除掉 MySQL 中的数据。这样的做法,其实相当于隐式地把 MySQL 作为了一个消息队列,并起到了缓冲的作用。

再者,MySQL 这种毕竟是行式数据库,如果你的数据数值居多,也可以跳过 MySQL,考虑直接存储到列式数据库中。下游的 Spark,Flink 这些消费端可能更喜欢读取列式数据。

最后一种选项是直接使用 Kafka 这种持久化的消息队列作为存储。DDIA 这本书中提到一个有趣的观点:数据库是日志的积分,日志是数据库的导数。
从某种意义上来说,两者所含有的信息是等价的,可以相互转换。所以直接使用消息队列作为数据存储也未尝不可。

总之,对于爬虫这种场景来说,最重要的特点是「读少写多」,按照这个思路去选择问题不大。除了这里提到的一些数据库,还有 Cassandra,FoundationDB 等一些
数据库没有提到,在特定的场景下也都值得考虑。对于存储的选择也不只是一个技术问题,可能更重要的是你的公司现在有什么,选一个比较合适的用就好了。

参考

  1. https://www.zhihu.com/question/479761564
  2. https://www.zhihu.com/question/36110917

Playwright: 比 Puppeteer 更好用的浏览器自动化工具

在 Playwright 之前,我一般会使用 Selenium 或者 Puppeteer 来进行浏览器自动化操作。然而,Selenium 经常会有一些奇怪的 bug, Puppeteer 则是没有官方 Python 版,非官方版本也只有 async 版本,并且也是有一些奇怪的 bug. 另外,众所周知,Python 的 Async API 并不是那么好用。

Playwright 是微软出品的浏览器自动化工具,大厂开源作品,代码质量应该是有足够保证的。而且它还官方支持同步版的 Python API, 同时支持三大浏览器,所以赶紧切换过来了。

特别注意 Playwright 的拼写,别把中间的 “w” 丢了。

安装

pip install playwright==1.8.0a1  # 很奇怪,必须指定版本,不指定会安装到一个古老的版本
python -m playwright install  # 安装浏览器,此处国内网络可能会有问题(你懂的),请自行解决

基本使用

Playwright 支持 Firefox/Chrome/WebKit(Safari). 其中 webkit 最轻量了,所以没有什么特殊需求最好使用 webkit, 不要使用 chromium.

from playwright.sync_api import sync_playwright as playwright

with playwright() as pw:
    webkit = pw.webkit.launch(headless=False)
    context = webkit.new_context(
        extra_http_headers={},
        ignore_https_erros=True,
        proxy={"server": "http://proxy.com"},
        user_agent="Mozilla/5.0...",
        viewport={"height": 1280, "width": 800}
    )  # 需要创建一个 context
    page = context.new_page()  # 创建一个新的页面
    page.goto("https://www.apple.com")
    print(page.content())
    webkit.close()

Playwright 官方推荐使用 with 语句来访问,不过如果你不喜欢的话,也可以用 pw.start() 和 pw.stop().

pw = playwright().start()
pw.webkit.launch()

Context 和 Page 没有提供 with 语句来确保关闭,可以使用 with closing 来实现,或者直接用 try…finally

from contextlib import closing

with closing(webkit.new_context()) as context:
    page = context.new_page()

context = webkit.new_context()
try:
    ...
finally:
    context.close()

新概念:Context

和 Puppeteer 不同的是,Playwright 新增了 context 的概念,每个 context 就像是一个独立的匿名模式会话,非常轻量,但是又完全隔离。比如说,可以在两个 context 中登录两个不同的账号,也可以在两个 context 中使用不同的代理。

通过 context 还可以设置 viewport, user_agent 等。

context = browser.new_context(
  user_agent='My user agent'
)
context = browser.new_context(
  viewport={ 'width': 1280, 'height': 1024 }
)
context = browser.new_context(
    http_credentials={"username": "bill", "password": "pa55w0rd"}
)

# new_context 其他几个比较有用的选项:
ignore_https_errors=False
proxy={"server": "http://example.com:3128", "bypass": ".example.com", "username": "", "password": ""}
extra_http_headers={"X-Header": ""}

context 中有一个很有用的函数context.add_init_script, 可以让我们设定在调用 context.new_page 的时候在页面中执行的脚本。

# hook 新建页面中的 Math.random 函数,总是返回 42
context.add_init_script(script="Math.random = () => 42;")
# 或者写在一个文件里
context.add_init_script(path="preload.js")

还可以使用 context.expose_bindingcontext.expose_function 来把 Python 函数暴露到页面中,不过个人感觉还是使用 addinitscript 暴露 JS 函数方便一些。

和 Puppeteer 一样,Playwright 的核心概念依然是 page, 核心 API 几乎都是 page 对象的方法。可以通过 context 来创建 page.

页面基本操作

按照官网文档,调用 page.goto(url) 后页面加载过程:

  1. 设定 url
  2. 通过网络加载解析页面
  3. 触发 page.on(“domcontentloaded”) 事件
  4. 执行页面的 js 脚本,加载静态资源
  5. 触发 page.on(“laod”) 事件
  6. 页面执行动态加载的脚本
  7. 当 500ms 都没有新的网络请求的时候,触发 networkidle 事件

page.goto(url) 会跳转到一个新的链接。默认情况下 Playwright 会等待到 load 状态。如果我们不关心加载的 CSS 图片等信息,可以改为等待到 domcontentloaded 状态,如果页面是 ajax 加载,那么我们需要等待到 networkidle 状态。如果 networkidle 也不合适的话,可以采用 page.waitforselector 等待某个元素出现。不过对于 click 等操作会自动等待。

page.goto(url, referer="", timeout=30, wait_until="domcontentloaded|load|networkidle")

Playwright 会自动等待元素处于可操作的稳定状态。当然也可以用 page.wait_for_* 函数来手工等待:

page.wait_for_event("event", event_predict, timeout)
page.wait_for_function(js_function)
page.wait_for_load_state(state="domcontentloaded|load|networkidle", timeout)
page.wait_for_selector(selector, timeout)
page.wait_for_timeout(timeout)  # 不推荐使用

对页面的操作方法主要有:

# selector 指的是 CSS 等表达式
page.click(selector)
page.fill(selector, value)  # 在 input 中填充值

# 例子
page.click("#search")

# 模拟按键
page.keyboard.press("Z")
# 支持的按键名字
# F1 - F12, Digit0- Digit9, KeyA- KeyZ, Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp
# 如果是单个字母,那么是大小写敏感的,比如 a 和 A 发送的是不同的按键
# 组合按键
page.keyboard.press("Shift+A")  # Shift, Ctrl, Meta, Alt, 其中 Meta 应该是相当于 Win/Cmd 键
# 其他的按键参考这里:https://playwright.dev/python/docs/api/class-keyboard

# 模拟输入
page.keyboard.type("hello")

在编写爬虫的过程中,向下滚动触发数据加载是个很常见的操作。但是如果想要向下滚动页面的话,似乎现在没有特别好的方法。本来我以为会有 page.scroll 这种方法的,然而并没有。在没有官方支持之前,暂时可以使用 page.keyboard.press("PageDown") 来向下滚动。

获取页面中的数据的主要方法有:

page.url  # url
page.title()  # title
page.content()  # 获取页面全文
page.inner_text(selector)  # element.inner_text()
page.inner_html(selector)
page.text_content(selector)
page.get_attribute(selector, attr)

# eval_on_selector 用于获取 DOM 中的值
page.eval_on_selector(selector, js_expression)
# 比如:
search_value = page.eval_on_selector("#search", "el => el.value")

# evaluate 用于获取页面中 JS 中的数据,比如说可以读取 window 中的值
result = page.evaluate("([x, y]) => Promise.resolve(x * y)", [7, 8])
print(result) # prints "56"

选择器表达式

在上面的代码中,我们使用了 CSS 表达式(比如#button)来选取元素。实际上,Playwright 还支持 XPath 和自己定义的两种简单表达式,并且是自动识别的。

自动识别实际上是有一点点坑的,凡是 . 开头的都会被认为是 CSS 表达式,比如说 .close, .pull-right 等等,但是 .//a 这种也会被认为是 CSS 导致选择失败,所以使用 XPath 的时候就不要用 . 开头了。

# 通过文本选择元素,这是 Playwright 自定义的一种表达式
page.click("text=login")

# 直接通过 id 选择
page.click("id=login")

# 通过 CSS 选择元素
page.click("#search")
# 除了常用的 CSS 表达式外,Playwright 还支持了几个新的伪类
# :has 表示包含某个元素的元素
page.click("article:has(div.prome)")
# :is 用来对自身做断言
page.click("button:is(:text('sign in'), :text('log in'))")
# :text 表示包含某个文本的元素
page.click("button:text('Sign in')")  # 包含
page.click("button:text-is('Sign is')")  # 严格匹配
page.click("button:text-matches('\w+')")  # 正则
# 还可以根据方位匹配
page.click("button:right-of(#search)")  # 右边
page.click("button:left-of(#search)")  # 左边
page.click("button:above(#search)")  # 上边
page.click("button:below(#search)")  # 下边
page.click("button:near(#search)")  # 50px 之内的元素

# 通过 XPath 选择
page.click("//button[@id='search'])")
# 所有 // 或者 .. 开头的表达式都会默认为 XPath 表达式

对于 CSS 表达式,还可以添加前缀css=来显式指定,比如说 css=.login 就相当于 .login.

除了上面介绍的四种表达式以外,Playwright 还支持使用 >> 组合表达式,也就是混合使用四种表达式。

page.click('css=nav >> text=Login')

复用 Cookies 等认证信息

在 Puppeteer 中,复用 Cookies 也是一个老大难问题了。这个是 Playwright 特别方便的一点,他可以直接导出 Cookies 和 LocalStorage, 然后在新的 Context 中使用。

# 保存状态
import json
storage = context.storage_state()
with open("state.json", "w") as f:
    f.write(json.dumps(storage))

# 加载状态
with open("state.json") as f:
    storage_state = json.loads(f.read())
context = browser.new_context(storage_state=storage_state)

监听事件

通过 page.on(event, fn) 可以来注册对应事件的处理函数:

def log_request(intercepted_request):
    print("a request was made:", intercepted_request.url)
page.on("request", log_request)
# sometime later...
page.remove_listener("request", log_request)

其中比较重要的就是 request 和 response 两个事件

拦截更改网络请求

可以通过 page.on(“request”) 和 page.on(“response”) 来监听请求和响应事件。

from playwright.sync_api import sync_playwright as playwright

def run(pw):
    browser = pw.webkit.launch()
    page = browser.new_page()
    # Subscribe to "request" and "response" events.
    page.on("request", lambda request: print(">>", request.method, request.url))
    page.on("response", lambda response: print("<<", response.status, response.url))
    page.goto("https://example.com")
    browser.close()

with playwright() as pw:
    run(pw)

其中 request 和 response 的属性和方法,可以查阅文档:https://playwright.dev/python/docs/api/class-request

通过 context.route, 还可以伪造修改拦截请求等。比如说,拦截所有的图片请求以减少带宽占用:

context = browser.new_context()
page = context.new_page()
# route 的参数默认是通配符,也可以传递编译好的正则表达式对象

# 1
context.route("**/*.{png,jpg,jpeg}", lambda route: route.abort())
# 2
context.route(re.compile(r"(\.png$)|(\.jpg$)"), lambda route: route.abort())
# 3
def no_static(route, req):
    if req.resource_type in {"image", "stylesheet", "media", "font"}:
        route.abort()
    else:
        route.continue_()
context.route("**/*", no_static)
page.goto("https://example.com")

其中 route 对象的相关属性和方法,可以查阅文档:https://playwright.dev/python/docs/api/class-route

灵活设置代理

Playwright 还可以很方便地设置代理。Puppeteer 在打开浏览器之后就无法在更改代理了,对于爬虫类应用非常不友好,而 Playwright 可以通过 Context 设置代理,这样就非常轻量,不用为了切换代理而重启浏览器。

context = browser.new_context(
    proxy={"server": "http://example.com:3128", "bypass": ".example.com", "username": "", "password": ""}
)

杀手级功能:录制操作直接生成代码

Playwright 的命令行还内置了一个有趣的功能:可以通过录制你的点击操作,直接生成 Python 代码。

python -m playwright codegen http://example.com/

Playwright 还有很多命令行功能,比如生成截图等等,可以通过 python -m playwright -h 查看。

其他

除此之外,Playwright 还支持处理页面弹出的窗口,模拟键盘,模拟鼠标拖动(用于滑动验证码),下载文件等等各种功能,请查看官方文档吧,这里不赘述了。对于写爬虫来说,Playwright 的几个特性可以说是秒杀 Puppeteer/Pyppeteer:

  1. 官方同步版本的 API
  2. 方便导入导出 Cookies
  3. 轻量级设置和切换代理
  4. 支持丰富的选择表达式

快点用起来吧!

后记:实战采坑

使用浏览器类工具做爬虫的最大隐患就是内存爆了,所以一定要记得使用 with 语句来确保浏览器关掉了。

参考

  1. https://playwright.dev/python/docs/core-concepts
  2. https://www.checklyhq.com/learn/headless/request-interception/

编写一个爬虫的思路,当遇到反爬时如何处理

本站/公众号/专栏不误正业好久了,今天终于写一篇爬虫的文章,然而并没有案例,也没有代码,只有踩过的坑和心法。

写了这么多年爬虫了,经常还是会撞上反爬机制。虽然大多数时候都能解决,但是毕竟反爬机制多种多样,有时候遇到一个许久不见的反爬机制,也会感到手生,一时想不上来应对方法,而浪费不少时间。最近又写了几个爬虫,接下来一段时间又不写了,趁着手还比较熟,记录一下备忘,方便大家也方便自己。

之前写过一篇常用的反爬虫封禁手段概览, 但是主要是从反爬的角度来的,这篇主要从写爬虫的角度来说说。

开章明义,当遇到反爬机制时,想要做到把数据爬下来,无非四个方法:

  1. 加代理
  2. 降速度
  3. 破解接口
  4. 多注册几个账户

好多文章为了显示自己高大上,吹些什么高并发呀,分布式,机器学习破解验证码的幺蛾子,都是扯淡。与其扯这些东西,不如老老实实把数据爬下来才是王道,如果非要扯上一些 fancy 的东西,那把监控做好比啥都重要

补充说明一下,本文探讨的是数据收集型的小型爬虫,也就是你要对少数站点在较短时间内收集大量信息。而非搜索引擎型全网爬虫,即对大量站点在较长时间内收集综合信息。(全网当然要上高并发了

为什么说爬虫不要扯高并发?

我们知道计算机程序按瓶颈不同大概分为两类,CPU 密集型和 IO 密集型。CPU 密集型就是偏重计算的任务,比如说编解码啥的;IO 密集型就是偏重于网络的任务,比如说下载或者 web 服务器。那么爬虫是哪种呢?你估计要回答 IO 密集型,恭喜你答对了。但是这不是我想说的重点,重点是爬虫不光是 IO 密集型的任务,实际上我想把它称作 IP 密集型任务。如果你不能增大自己 IP 的数量,我实在不知道所谓的高并发有啥卵用。

什么是 IP 密集型任务呢?按照上面的定义我们知道,也就是说,对爬虫来说,最瓶颈的地方其实是你持有的 IP 的数量!作为一个合格的爬虫编写者,你肯定已经擅长伪造各种 HTTP headers, 破解 JS 的加密参数,但是唯独一个 — 来源 IP — 你是无法伪造的。好多看起来很难搞的事情,如果对方站点的小霸王服务器撑得住,只要加上足够的 IP 就很简单啦,不用绞尽脑汁去想各种策略了。

为什么不要用现成的框架?

上面说了,所谓的”高并发”对爬虫没有任何卵用,那么像是 Scrapy 这种采用了协程以便提高并发的框架我就不是很懂了。以前我专门写过一篇为什么不要用 Scrapy 的文章,所以这里就不再展开细说了。

另外如果你爬虫写多了肯定有自己的一套东西了,这时候你可能会有自己的一个小框架,这是可以的。但是我还是想提两点:

  1. 千万不要做成完全从模板生成新的爬虫项目的功能。假如你改了模板里的一个 bug 怎么办?以前生成的爬虫还挨个修改吗?
  2. 框架尽量简单,把可以复用的功能提取成单独的 utility 函数或者库。难免有需要改框架或者不适用框架的时候,这时候依然可以复用单独的模块。

拿到抓取任务时的思路

言归正传,我们开始说当拿到一个站点需要爬取时该如何处理。

数据量较小的爬取

首先开始 easy 模式。如果你要抓的网站结构比较简单,而你要的数据也比较少。那么你首先要考虑的是不要编写爬虫. 在浏览器控制台里写个 js 表达式 console.log 一下说不定就把数据导出来了。

如果你要的数据稍微多一点时,这时候点开一个页面然后复制数据出来可能就比较复杂了。这时候可以考虑写个小脚本,别直接 while True 写个死循环就了事儿,每爬一个页面至少 time.sleep(1) 是对对方网站最起码的尊重。当然你的老板可能要数据比较急,但是多少也要悠着点。

确保自己的请求没有明显的爬虫特征

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

  1. Host 一般各种库都已经填充了
  2. Connection 填 Keep-Alive, 正经浏览器不可能是 Close。
  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

把这些填充上,至少不会被最低级的反爬手段封禁了。稍微敏感点的网站,都会禁掉 curlpython-urllib 这种 User-Agent 的。

另一个比较重要的事情是 Cookie,有些网站比较傻,你直接不处理 Cookie 就每次都把你当新用户,不过这种网站比较少了。还有的网站需要在首页获得一个匿名 Cookie 然后一直携带这个匿名 Cookie 就好了。还有一些网站需要 Cookie 每次请求后更新,就像浏览器一样。这些都是比较简单的方法,一开始试试就知道是哪种了。对于需要登录后的 Cookie 的,那是登录反爬的范畴,后面详谈。

陷阱链接,有一些网站上会有一些隐藏的链接,通过 CSS 或者其他方式让正常用户看不到,而爬虫不管啊,拿到链接就是一个爬,以此来识别爬虫和正常用户,不过这也都是小众做法,很少遇到了。

浏览器动态加载怎么办?

初学者在这里可能遇到第一个坑:动态网页。这时候可能是个好事儿,也可能是个坏事儿。如果是动态网页,数据自然是 ajax 加载的,如果 ajax 请求没有参数验证的话,那么就简单了,只是从解析 html 变成了解析 json 而已。

另一种情况是接口是需要参数验证的,这时候又分两种处理方式:

  1. 如果只是爬一下数据,直接上浏览器,爬完了事儿。
  2. 如果嫌浏览器资源占用太多,那么往往就会需要破解接口,这种情况下需要一定的 JS 逆向能力。

有的网站对浏览器甚至还做了一些限制,他会检测你的浏览器是正常的用户浏览器还是用程序控制的自动化浏览器。不过这都不是问题,无非就是伪造一下 webdriver 对象和 navigator 对象罢了。这个我也写过一篇具体文章讲如何伪造。

当然这时候也可能遇到情况比较简单的特殊情况,那就是对方的某个更新接口是固定的,而且加密参数里面没有时间戳,那么直接重复请求这个接口就行了。一般来说这种情况算是”瞎猫撞见死耗子”, 多数情况下,接口的签名都校验了搜索的参数和时间戳,也就是你变换关键词,或者想重放请求的话是行不通的,这时候就老老实实破解吧。

一般破解 JS 其实也都不难,常用的信息摘要,或者加密方法也没多少。不过先别接着破解,花上五分钟搜索一下有没有别人已经破解过了,可能就省了你半天到几天的功夫,何乐而不为呢?

实在要开始破解的话,在 JS 的控制台中全局搜索 (Opt+Cmd+F) 一下 AES, MD5 之类的关键词,可能就有收获。另一方面在 ajax 请求上加上断点,逐步找到加密的函数。

找到加密函数之后,如果简单一点的,直接写在一个函数里的,可以抽取出来直接调用 node 执行算出参数,或者你比较勤快用 Python 重写一下都可以。然而比较棘手的是有些函数是和 window 对象或者 DOM 绑定在一起的,这时候也可以考虑把整个 JS 文件保存下来,补全需要的接口。

最常见的 IP 封禁

正如我们前面说的,作为一个爬虫老手,伪造和破解简单的加密都是基本功了,比较蛋疼的还是封禁 IP, 这你下什么苦功夫也没法解决的,是一个资本问题。

当我们爬取的速率比较快的时候,就可能被对方拉黑 IP, 这时候有可能是临时性拉黑,有可能是持续性拉黑,有可能是永久性拉黑。

永久性拉黑比较狠,也没啥办法,直接换 IP 吧。需要区分的是临时性的拉黑和持续性拉黑。如果是临时性拉黑,也就是你的请求超过阈值了就会请求失败,但是过段时间自己又恢复了,那么你程序逻辑也不用改,反正就一直请求呗,总有数据的。如果是持续性拉黑就比较坑了,也就是说如果你被拉黑了还不停止请求,那么就一直出不了小黑屋,必须停下来 sleep 几秒。这时候你的程序的逻辑就需要适应这种机制。如果是单独的脚本还好,对于一些标准化的系统就需要考虑这些机制。

一种比较简单的策略是,sleep 的间隔应该指数增加,比如第一次 sleep 10 秒,发现还是被限制,那么就 sleep 20 秒,直到一个比较大的上限或者是解除封禁。

当我们需要换 IP 的时候,肯定不能手工去记得过几分钟换一下子 IP 了,那也太烦人了,一般是需要一个 IP 池子的。

代理 IP 按照质量和来源又分为几类:

  1. 比较垃圾的公用 IP
  2. 比较稳定的机房 IP
  3. 民用网段 IP

网上有一些站点会提供一些免费的代理 IP, 估计他们都是扫来的。这些 IP 可能都有无数的程序在扫描,使用他们,所以可以说是公用的 IP 了。通过收集验证这些 IP, 可以构造一个代理池子。如果实在很穷,或者抓取量不是很大,可以用这种 IP. 虽然这些 IP 特别慢,失败率特别高,总比用自己的一个出口 IP 要好一些。

比较稳定的机房 IP. 这种一般就需要花钱买了,稍微想多抓点数据,一个月 ¥100 那是起步。对付大多数的抓取已经足够了。

对于有一些变态的站点,他们甚至会验证来源 IP 的用途。比如说一看你 IP 来自阿里云机房,啥也不说直接拉黑。这时候就需要所谓的”民用 IP”了。这种有专门的厂商和 App 合作来提供民用网络出口,也可以自己买 ADSL 机器自动拨号搭建,反正成本都是非常非常高了,一个月至少 1000 起步了。

带上账户或者验证码

IP 毕竟算是匿名的。对于一些数据更敏感的网站来说,他们可能要求你登录后才能访问。如果数据不多,那么直接用自己账户跑一下就完了。如果每个账户的访问额度有限,或者要爬的数据特别多,那可能需要注册不少账户,这时候只要不是付费账户,那么其实都好说(除了微信). 你可以选择:

  • 买一些账号,比如说微博账号也就一块半一个而已。
  • 自己注册一些,网上有免费邮箱,也有手机短信接码平台。

这时候不要急着去写个脚本自动化注册啥的,可能你并不需要那么多的账户。

比需要账户稍微弱一点的限制是验证码,图文验证码现在都不是问题了,直接打码平台或者训练个模型都很简单。复杂一点的点按,图片选字等就当饭后甜点吧,弄得出来弄不出来全看运气了。在这里我想说的一点是,请分辨一下你要爬的网站是每次请求必须验证码,还是在封禁 IP 之前出验证码。如果不是每次请求都出验证码,直接加大代理池吧,没必要抠这些东西,真的,时间才是最宝贵的。

不过这里需要特别注意的一点是:一定要考虑清楚其中的法律风险,需要账户访问已经说明这不是公开数据了,可能会触发对方的商业利益或者触犯用户的隐私,一定三思而后爬。

事情没那么简单

如果一个网站只采用一种手段,那么我们分析起问题来就简单了。然而遗憾的是,基本没这种好事儿。比如说一个网站可能即检测了浏览器的 webdriver, 而且还要封 IP, 这时候你就得用浏览器再加上代理,有时候给浏览器设置代理这件事情还挺复杂。还有可能你用账户 Cookies 爬起来飞快,但是竟然还会封 IP, 哼哧哼哧加上了代理池,又发现账户不能换登录 IP, 简直想骂人。

还有一些神仙网站,比如说淘宝或者裁判文书网,可能本文说的都完全没有任何价值,不过好在我大概不会碰这些网站了。

选哪些接口爬

其实我发现一般常见爬虫任务无非几种:

  1. 找到网站的一个列表,把里面数据全都爬下来。
  2. 自己弄些 id 或者关键词,通过查询或者搜索接口把数据全都爬下来。
  3. 刷网站的一些更新或者推荐接口,以期不断抓取。

首选的肯定是无状态的接口,搜索接口在大多说网站还是可以直接就拿来用的。如果有需要登录的,也有不需要登录的接口,那想都不用想,肯定爬不需要登录的接口。哪怕登录了,好多还是会封 IP, 何必呢?有些网站的详情或者翻页可能就需要登录了,实在没办法也只能走这些接口。这时候一定要做好登录出口和普通爬虫的代理池隔离。

要判断清楚爬虫任务是爬全量数据还是增量数据。一般来说爬全量数据的需求都有点扯,一定要和需求方 argue 一下,可能对方根本就没想清楚,觉得先把数据存下来然后慢慢再想怎么用,对于这种傻 X 需求一定要顶回去,别急着跪舔或者炫技。如果一定要爬全量,那也可以慢慢来,不用非着急忙慌的把对方网站都搞挂了,这并不是啥值得炫耀的,而是应该被鄙视。而且一般网站也不会把全量数据让你都能看到,比如可能只能翻 500 页,这时候只能通过细分查询条件来尽可能多地获得一些数据。增量的话一般就好说一点,就像上面说的,定时刷一下更新或者推荐接口就好了。

要爬 App 吗?一般来说现在的网站还是有足够的数据的,除非是一些只有 App 而没有网站的站点,那就没办法了。App 的破解和 JS 其实思路一样,但是可能好多 App 加了壳,或者把加密算法写到了 C 里面,相比 JS 来说,完全不是一个数量级了。对于 App 的破解我基本一窍不通,也就是靠写一些网页爬虫混口饭吃。

你应该庆幸的一点是,你需要写的只是一个爬虫,而不是发帖的机器人,一般网站对于这种制造垃圾数据的防范机制肯定比爬虫要复杂很多。

既然谈到破解 App 了,那么再多说一点。有不少同学觉得从爬虫出发,往深了发展就是逆向,那可就大错特错了,有两点:

  1. 搞逆向工资很低啊!甚至比爬虫还低,不信自己去招聘 App 上看看。你从一个工资高的搞到一个工资低的,这不是给自己找不自在吗?为什么不往钱更多的后端发展呢?哪怕洗洗数据,玩儿玩儿 Spark/Flink 都比搞爬虫强啊,更别说逆向了。好多同学以爬虫工程师自居,实际上这个身份认知就谬之大矣,爬虫就是一个小小的工具而已,用不上作为一种职位。如果基础差,多学点计算机理论知识,后端工程师才称得上是一个职位。
  2. 相比”拿来”来说,”创造”是一件更美好的事情。爬虫当然是拿来别人的东西,逆向就更狠了,别人都明说了不给的东西,还要抢过来,这样其实是不好的,更别说其中的法律风险了。自己去做一个 App 或者一个网站,一个其他产品,让真实的用户来使用,这样的感觉更好一点。而且也好公开吹牛逼啊,你做了一个 App 有 10 万日活可以公开吹逼,但是你把人家网站底裤扒光了,总不好意思大张旗鼓吧?爬虫毕竟是灰色地带的事情,锦衣夜行真的很不爽。

这里再次特别强调一下:破解别人的 App 可能是非法行为,需要负法律责任。

爬来的数据是否可信

当你费劲吧啦把数据搞定了,还有一个灵魂问题,爬来的数据可信吗?

如果用来分析的数据本来就是错的,那么得出的结论必然也是有问题的。比如 2016 年美国大选中,由于川普的支持者经常被侮辱,导致在电话调查选民中,大家都声称自己支持希拉里,可是实际上大家都投给了川普。电话调查的结果本来就是错的,所以大家都认为希拉里会赢。川普团队则采取的是问选民你认为你的邻居会投谁,从而得到了正确结果。

爬虫爬到的数据中也有可能是有问题的,比如租房网站的假房源,招聘网站上的虚假职位,用户故意不填写真实信息以保护隐私等等;微信文章被刷多的阅读数;而且编写不良的爬虫很可能误入蜜罐,得到的数据更有问题。

比如说借助爬来的新闻分析房产数据,实际上住建部禁止发布涨价相关预测,也就是对于市场的情绪表达是有影响的,那么我们如果按照这个数据来做预测显然是不对的。

总体来说,遇到蜜罐的情况是比较简单的,因为对方伪造数据也需要花费精力,所以伪造的数据一般都是粗制滥造,特征很明显的。而其他的一些假数据问题都是业务问题了,在爬虫程序层面不好也不用解决。

最后,总结一下

所以总结下来,我觉得遇到一个网站的需要考虑的思路是:

  1. 预估下需要爬的数据和时间节点,算出来每秒需要爬多少数据。别上来就设计个啥架构,八成根本用不上。
  2. 如果需要的速率比较小,那么直接 time.sleep(5) 慢慢跑着,也就是尽量不要触发封禁。
  3. 尽量找到一个公开的,不需要登录就能访问的接口或者页面,直接上代理池,别想那么多别的。
  4. 能从一个接口拿到的数据,不要再去多请求其他的接口,尽量减少访问量。
  5. 能很快破解的 JS 也可以破解一下,比较复杂的直接上浏览器,浏览器就直接做好伪装,省得出问题。
  6. 需要登录认证的一定要考虑 Cookie 异地失效的问题,最好使用单独的高质量 IP. 做一套路由机制,保证每个 Cookie 都从同一个 IP 出去。
  7. 爬来的数据还可能是假数据,要仔细甄别。

总之,一次解决一个问题,不要同时触发两个反爬问题,容易按下葫芦起了瓢。

就是这些吧,本文核心观点 — 最简单粗暴的还是加大电量(误加 IP 池,如果一个不够,那就两个。加钱能解决的问题都不是问题。好多同学可能觉得你这叫哪门子爬虫啊,分布式系统也没有,最好玩的逆向你说去网上抄别人的答案,哪还有毛意思啊!然而,很遗憾,这才是现实世界,对于业务来说,爬虫最重要的是你拿到有用的数据,而不是写代码写牛逼了,有这时间回家陪陪家人不好么~

参考文献

本文没有任何参考文献,纯意识流瞎写。文中引用了之前写的几篇文章,懒得贴了,感兴趣自己在网站或者公众号找吧。

PS: 监控很重要,爬虫最怕跑着跑着对面改版了或者加反爬了,有了监控才好及时发现问题。关于监控,强烈推荐 Prometheus, 可以参考我以前的文章。

海外爬虫 IP 池

https://github.com/constverum/ProxyBroker/blob/master/proxybroker/providers.py

https://list.proxylistplus.com/SSL-List-1

https://list.proxylistplus.com/Fresh-HTTP-Proxy-List-1

https://cool-proxy.net/
https://github.com/imWildCat/scylla/blob/master/scylla/providers/coolproxyprovider.py

https://free-proxy-list.net/
https://github.com/imWildCat/scylla/blob/master/scylla/providers/freeproxylist_provider.py

https://proxyhttp.net/
https://github.com/imWildCat/scylla/blob/master/scylla/providers/httpproxyprovider.py

https://www.ipaddress.com/proxy-list/
https://github.com/imWildCat/scylla/blob/master/scylla/providers/ipaddress_provider.py

http://proxy-list.org/english/index.php
https://github.com/imWildCat/scylla/blob/master/scylla/providers/proxylistprovider.py

https://raw.githubusercontent.com/sunny9577/proxy-scraper/master/proxies.json
https://github.com/imWildCat/scylla/blob/master/scylla/providers/proxyscraperprovider.py

http://www.proxylists.net/countries.html
https://github.com/imWildCat/scylla/blob/master/scylla/providers/proxylists_provider.py

https://github.com/imWildCat/scylla/blob/master/scylla/providers/proxynova_provider.py

http://pubproxy.com/api/proxy?limit=5&format=txt&type=http&level=anonymous&lastcheck=60&nocountry=CN

https://github.com/imWildCat/scylla/blob/master/scylla/providers/rmccurdy_provider.py

https://github.com/imWildCat/scylla/blob/master/scylla/providers/spysmeprovider.py

https://github.com/imWildCat/scylla/blob/master/scylla/providers/spysoneprovider.py

https://github.com/imWildCat/scylla/blob/master/scylla/providers/thespeedXprovider.py

https://proxy-daily.com/

http://ab57.ru/downloads/proxyold.txt

http://www.proxylists.net/http.txt

http://www.proxylists.net/http_highanon.txt

http://pubproxy.com/api/proxy?limit=5&format=txt&type=http&level=anonymous&lastcheck=60&nocountry=CN
http://pubproxy.com/api/proxy?limit=5&format=txt&type=http&level=anonymous&last_check=60&country=CN

http://free-proxy.cz/zh/proxylist/country/CN/all/ping/all
https://github.com/phpgao/proxypool/blob/master/job/htmlcz.go

http://nntime.com/proxy-updated-01.htm
https://github.com/phpgao/proxypool/blob/master/job/htmlnntime.go

https://premproxy.com/list/time-01.htm
https://github.com/phpgao/proxypool/blob/master/job/htmlpremproxy.go

https://github.com/phpgao/proxypool/blob/master/job/htmlproxydb.go

https://github.com/phpgao/proxypool/blob/master/job/htmlsite_digger.go

https://github.com/phpgao/proxypool/blob/master/job/htmlultraproxies.go

https://github.com/phpgao/proxypool/blob/master/job/htmlus_proxy.go

https://github.com/phpgao/proxypool/blob/master/job/jsoncool_proxy.go

https://github.com/phpgao/proxypool/blob/master/job/realiveproxy.go

https://github.com/phpgao/proxypool/blob/master/job/reblackhat.go

https://github.com/phpgao/proxypool/blob/master/job/redogdev.go

https://github.com/phpgao/proxypool/blob/master/job/refreeip.go

https://github.com/phpgao/proxypool/blob/master/job/rehttptunnel.go

https://github.com/phpgao/proxypool/blob/master/job/remy_proxy.go

https://github.com/phpgao/proxypool/blob/master/job/renewproxy.go

https://github.com/phpgao/proxypool/blob/master/job/reproxyiplist.go

https://github.com/phpgao/proxypool/blob/master/job/reproxylist.go

https://github.com/phpgao/proxypool/blob/master/job/rexseo.go

https://github.com/derekhe/ProxyPool/blob/master/lib/proxybroker/providers.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/listendeprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/nordprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/pdbprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/plpprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/premprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/sslprovider.py

https://github.com/Jiramew/spoon/blob/master/spoonserver/proxy/webprovider.py

https://www.freeproxy.world/

http://proxydb.net/

http://www.xsdaili.cn/

https://github.com/bluet/proxybroker2/blob/master/proxybroker/providers.py

https://github.com/nicksherron/proxi/blob/master/internal/providers.go

# def freeProxy10():
#     """
#     墙外网站 cn-proxy
#     :return:
#     """
#     urls = ['http://cn-proxy.com/', 'http://cn-proxy.com/archives/218']
#     request = WebRequest()
#     for url in urls:
#         r = request.get(url, timeout=10)
#         proxies = re.findall(r'<td>(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})</td>[\w\W]<td>(\d+)</td>', r.text)
#         for proxy in proxies:
#             yield ':'.join(proxy)

# @staticmethod
# def freeProxy11():
#     """
#     https://proxy-list.org/english/index.php
#     :return:
#     """
#     urls = ['https://proxy-list.org/english/index.php?p=%s' % n for n in range(1, 10)]
#     request = WebRequest()
#     import base64
#     for url in urls:
#         r = request.get(url, timeout=10)
#         proxies = re.findall(r"Proxy\('(.*?)'\)", r.text)
#         for proxy in proxies:
#             yield base64.b64decode(proxy).decode()

# @staticmethod
# def freeProxy12():
#     urls = ['https://list.proxylistplus.com/Fresh-HTTP-Proxy-List-1']
#     request = WebRequest()
#     for url in urls:
#         r = request.get(url, timeout=10)
#         proxies = re.findall(r'<td>(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})</td>[\s\S]*?<td>(\d+)</td>', r.text)
#         for proxy in proxies:
#             yield ':'.join(proxy)

抓取新浪微博的数据

新浪微博的数据总体来说可以通过几个接口获取:

  1. 网页版 (weibo.com)
  2. 移动版 (m.weibo.cn) JSON 接口数据很丰富
  3. WAP 版 (weibo.cn), 数据经常不全。和上面两个接口的 ID 不是一套。
  4. 开放平台的接口,需要创建一个应用然后使用,感觉局限性挺大的,除非抓取量很小。

新浪微博的数据有两套 id, 一个叫 id/mid, 是数字类型的,另一套叫做 bid, 是字符类型的。

根据关键词抓取指定微博

可以使用移动端的微博,翻页也不需要登录,随便撸

https://m.weibo.cn/api/container/getIndex?containerid=100103type%3D1%26q%3D%E8%8B%B9%E6%9E%9C%E8%BE%93%E5%85%A5%E6%B3%95&page_type=searchall&page=99

抓取微博的评论

这个接口翻页需要登录

https://m.weibo.cn/comments/hotflow?id=4282494510984677&mid=4282494510984677&max_id_type=0

抓取单条微博的接口

不需要登录,随便撸

https://m.weibo.cn/statuses/show?id=JgPmzBaKZ

抓取用户微博

不需要登录,翻页也不需要,随便撸

https://m.weibo.cn/api/container/getIndex?uid=5524254784&t=0&luicode=10000011&containerid=1076035524254784&since_id=4378269463566752

几个尚未查看的项目

  1. https://github.com/nghuyong/WeiboSpider

参考

  1. 移动端关键词抓取
  2. 微博搜索 API
  3. 移动端抓包

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

为什么不使用 scrapy?

请参考更新版本: https://yifei.me/note/838

最近面了几家公司,每当我提到头条的爬虫都是自己写的时候,对方一个下意识的问题就是: “为什么不使用开源的 scrapy?”。实际上我在头条的 lead 就是 scrapy 的 contributor,而他自己也不用自己的框架,显然说明 scrapy 不适合大型项目,那么具体问题在哪儿呢?今天终于有时间了,详细写写这个问题。

爬虫并不需要一个框架

Web 服务器是一个爬虫可以抽象出来的是各种组件。而 scrapy 太简陋了,比如说去重,直接用的是内存中的一个集合。如果要依赖 scrapy 写一个大型的爬虫,几乎每个组件都要自己实现,那有何必用 scrapy 呢?

scrapy 不是完整的爬虫框架

一个完整的爬虫至少需要两部分,fetcher 和 frontier。其中 fetcher 用于下载网页,而 frontier 用于调度。scrapy 重点实现的是 fetcher 部分,也就是下载部分。

scrapy 依赖 twisted

这一点导致 scrapy 深入后曲线非常地陡峭,要想了解一些内部的机理,必须对 twisted 比较明了。而 twisted 正如它的名字一样,是非常扭曲的一些概念,虽然性能非常好,但是要理解起来是要花上不少时间的。

scrapy 适合的领域

scrapy 主要适合一次性地从指定的站点爬取一些数据

最重要的并不是你使用不使用 Scrapy,而是你不能为每一站点去单独写一个爬虫的脚本。代码的灵活度实在太大了,对于没有足够经验的工程师来说,写出来的脚本可能很难维护。重点是要把主循环掌握在爬虫平台的手中,而不是让每一个脚本都各行其是。

参考

  1. scrapy 源码解读

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

最近面了几家公司,每当我提到头条的爬虫都是自己写的时候,对方一个下意识的问题就是:“为什么不使用开源的 scrapy?”。实际上我在头条的 lead 就是 scrapy 的 contributor,而他自己也不用自己的框架,显然说明 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。

scrapy 依赖 twisted 这一点导致 scrapy 深入后曲线非常地陡峭,要想了解一些内部的机理,必须对 twisted 比较明了。而 twisted 正如它的名字一样,是非常扭曲的一些概念,虽然性能非常好,但是要理解起来是要花上不少时间的。

Web 服务器是一个爬虫可以抽象出来的是各种组件。而 scrapy 太简陋了,比如说去重,直接用的是内存中的一个集合。如果要依赖 scrapy 写一个大型的爬虫,几乎每个组件都要自己实现,那有何必用 scrapy 呢?

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

对于大型爬虫系统,最重要的并不是你使用不使用 Scrapy,而是你不能为每一站点去单独写一个爬虫的脚本。代码的灵活度实在太大了,对于没有足够经验的工程师来说,写出来的脚本可能很难维护。重点是要把主循环掌握在爬虫平台的手中,而不是让每一个脚本都各行其是。

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

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

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

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

参考

  1. scrapy 源码解读

知乎移动端接口分析

最近想注册一些知乎的机器人玩玩儿,比如给自己点赞之类的,通过抓包分析,获得了完整注册登录流程。

注册和登录抓包

1 POST https://api.zhihu.com/auth/digits
      ← 401 application/json 97b 246ms
2 GET https://api.zhihu.com/captcha
     ← 200 application/json 22b 233ms
3 PUT https://api.zhihu.com/captcha
     ← 202 application/json 5.46k 323ms
4 POST https://api.zhihu.com/captcha
      ← 201 application/json 16b 295ms
5 POST https://api.zhihu.com/sms/digits
      ← 201 application/json 16b 353ms
6 POST https://api.zhihu.com/validate/digits
      ← 201 application/json 16b 409ms
7 POST https://api.zhihu.com/validate/register_form
      ← 200 application/json 16b 279ms
8 POST https://api.zhihu.com/register
      ← 201 application/json 761b 529ms

逐行分析一下每个包:

  1. 这个请求发送了 username: +86155xxxxxxxx 请求,然后返回了 缺少验证码票据,应该是表示缺少验证码。
  2. 应该不是请求验证码,而是请求是否需要验证码,返回了"show_captcha": false,虽然表示的是不需要验证码,但是还是弹出了验证码,奇怪。
  3. 注意这个请求是 PUT,POST 参数height: 60, width: 240。然后返回了验证码:{"img_base64": ...}, base64 解码后就是验证码
  4. 这一步 POST 正确的 captcha 并通过验证,参数是:input_text: nxa8, 返回是:{ "success": true }
  5. 这一步请求发送短信验证码,POST 参数是:phone_no: +86155xxxxxxxx, 发挥是:{ "success": true }
  6. 提交验证码,POST 参数是:phone_no: +86155xxxxxxxx, digits: xxxxxx, 返回是:{ "success": true }
  7. 填写用户信息,POST 参数是:phone_no: +86155xxxxxxxx, gender: 0, fullname: XXX, 返回是:{ "success": true }
  8. 上一步注册了用户,这一步是向知乎请求新的 access token。

请求 POST 参数:

digits:        865405
fullname:      Lucindai
phone_no:      +8615568995304
register_type: phone_digits

返回数据如下:

{
    "access_token": "...",
    "cookie": { },
    "expires_in": 2592000,
    "lock_in": 1800,
    "old_id": 155681178,
    "refresh_token": "...",
    "token_type": "bearer",
    "uid": "...",
    "unlock_ticket": "...",
    "user_id":...
}

其中的 refresh token 和 access token 都是 OAuth2 中的参数,可以用于使用 OAuth2 访问知乎的 API。可以使用 zhihu_oauth 这个库来访问知乎。

知乎的 API 还需要在 header 中设定一些特殊参数,可以参考 zhihu_oauth 中的参数

再注册成功之后还应该设定密码,这样之后就可以使用密码登录了。

PUT https://api.zhihu.com/account/password
new_password=xxxxxx

与桌面 API 的转换

有意思的是,知乎的移动版 API 实际上和桌面版 API 是一致的