前言

之前在公众号转载了一篇 使用 Python 模拟登录知乎 , 使用了目前实现爬虫比较常用的 Web 登录方式。我以前写爬虫选择的方式是:

  1. 如果对方网站有开放平台,满足需求且比较容易获取 API 权限,优先使用 API。
  2. 如果网页登录及验证非常容易,甚至都不用登录就可以获取爬取网页,也是可以的。但是 Web 抓取不是最优先的,因为 Web 页面结构会改变、登录验证方式也会不断更新,可以感受到层出不断的验证码方式,烦。Web 抓取,如果对应的移动适配的页面满足我会优先考虑移动端抓取,限制要少一些。我在知乎回答「 你见过哪些令你瞠目结舌的爬虫技巧? 」最后提到过:「第二条: 不要只看 Web 网站,还有移动版、 App 和 H5, 它们的反爬虫措施一般比较少,所有社交网站爬虫,优先选择爬移动版。 」,不过这条大家好像都是直接忽略的... 忧伤
  3. 当前 2 种都不好使的时候,虽然没有公开的 API,但是只要这个应用有移动版本,就好办....

昨天喜闻知乎获得了新一轮的融资,晚上赶紧研究了下通过抓包获取知乎 API 的方法,分享给大家。由于之前我写的爬虫被对方寄了律师函(像豆瓣、知乎这种胸襟的公司毕竟是少数),读者请不要分享到掘金等平台(知乎可以),小范围传播就好了,感谢!

昨晚灵机一动的原因是由于之前在写「 我的 2016 年 」的时候,fork 了 zhihu-oauth ,添加了 following 接口,跑了个获取参与我的 Live 的人中有多少关注者的脚本。

其实本文并没有超出 zhihu-oauth 的技术实现。但是我并不想把我的修改提 PR 合并给上游,因为对一些代码和实现的理解有一些冲突。

但是能有本文还是非常感谢 zhihu-oauth,它其实就是用知乎 API 实现的抓取,我本文的提到的技术和代码并没有超过它的范畴。但是还是有一些区别:

  1. 代码量。zhihu-oauth 是一个中型组织结构,目录模块分配合理,我这个是它的抓取核心的简化版本,代码量少了很多。「Python 之禅」里面有一句「Simple is better than complex.」,我个人不喜欢看结构复杂,尤其有黑科技的项目。当然会这样一般有炫技、作者对写项目的理解、设计能力还是历史遗留等原因。我比较喜欢简单粗暴的展示核心,代码能力在我看来有一个方面是能把复杂的事情非常简单化的表达,以至于让初学 Python 不久的工程师看起来也会愉悦,我正在朝着这个方向努力。
  2. 移动设备。zhihu-oauth 使用的安卓,我这篇文章用到的是 IOS,且是目前最新版。
  3. 展开细节。zhihu-oauth 是一个开箱即用的项目,你可以不必读源码甚至不会写爬虫即可。而我今天是给大家讲整个抓取过程都发生了什么,怎么完成抓取的。相信大家看完之后去抓其他应用的包也会容易很多。
  4. 其他实现细节。比如 zhihu-oauth 使用了 pickle 来序列化 token 的结果,它是 Python 独有的,可读性和安全性都不好,我改用了 json。

好吧,我们开始。

IOS 抓包

安卓的权限控制比较松,能比较方便的抓包。但是 IOS 由于苹果的一些政策,只能迂回的获的,我使用了 Charles ,Charles 的安装设置就不说了,Google 能找到一堆教程。开始抓包后,然后在 IPhone 上设置手动的 HTTP 代理。

接着就是退出知乎,重新登录。

可以看到下面 Charles 界面左侧会出现 api.zhihu.com 的一项,点开可以看到 sign_in 项,再点开。在右侧就会出现 API 的信息。但我们先看 Contents 里面的 Headers 部分:

这里面信息很多:

  1. 登录 API 地址是:https://api.zhihu.com/sign_in
  2. 登录请求使用 POST (废话)
  3. 登录需要添加一大坨自定义头信息,还要包含身份认证

其中自定义的头信息可以设置成常量,放进 config.py:

API_VERSION = '3.0.42'
APP_VERSION = '3.28.0'
APP_BUILD = 'release'
UUID = 'AJDA7XkI9glLBWc85sk-nJ_6F0jqALu4AlY='
UA = 'osee2unifiedRelease/3.28.0 (iPhone; iOS 10.2; Scale/2.00)'
APP_ZA = 'OS=iOS&Release=10.2&Model=iPhone8,1&VersionName=3.28.0&VersionCode=558&Width=750&Height='
CLIENT_ID = '8d5227e0aaaa4797a763ac64e0c3b8'
APP_SECRET = b'ecbefbf6b17e47ecb9035107866380'

其中 CLIENT_ID 和 APP_SECRET 基于安全考虑用的是 zhihu-oauth 中默认的。我之前介绍过用 local_config 的方法替换 config 里面的默认配置,让一些关键常量不必放入版本库。

有一些一般(严谨点)是不会变的, 比如 API 地址,所以放进 settings.py

ZHIHU_API_ROOT = 'https://api.zhihu.com'
LOGIN_URL = ZHIHU_API_ROOT + '/sign_in'
CAPTCHA_URL = ZHIHU_API_ROOT + '/captcha'

我们知道 requests 这个库是支持身份认证的,在这里我们按照人家的玩法,自定义 ZhihuOAuth 类:

from requests.auth import AuthBase

from config import (
    API_VERSION, APP_VERSION, APP_BUILD, UUID, UA, APP_ZA, CLIENT_ID)


class ZhihuOAuth(AuthBase):
    def __init__(self, token=None):
        self._token = token

    def __call__(self, r):
        r.headers['X-API-Version'] = API_VERSION
        r.headers['X-APP_VERSION'] = APP_VERSION
        r.headers['X-APP-Build'] = APP_BUILD
        r.headers['x-app-za'] = APP_ZA
        r.headers['X-UDID'] = UUID
        r.headers['User-Agent'] = UA
        if self._token is None:
            auth_str = 'oauth {client_id}'.format(
               client_id=CLIENT_ID
            )
        else:
            auth_str = '{type} {token}'.format(
                type=str(self._token.type.capitalize()),
                token=str(self._token.token)
            )
        r.headers['Authorization'] = auth_str
        return r

然后就可以使用诸如requests.get('https://api.xxx.com/yy, auth=ZhihuOAuth())的方式了,不需要粗暴的拼 headers。

上面的 auth_str 有 2 种,这是由于在为登录前是没有 token 的,使用的是 oauth,在登录之后会拿到 token。之后在调取其他 API 接口就需要这个 token 了。之后还会提到。

接着,我们看一下提交的表单和请求登录成功后返回的内容:

这次选择的是 Form,返回的结果是切换到 JSON Text 这个 Tab 看到的。处于安全性的考虑,我隐去了一些敏感信息。

当成功之后,会返回一个 access_token,也包含了 token 类型是 bearer。之后的 API 调用就要使用和这个 token 了。假如我们在刷首页的 feed,会有这样的一个请求:

注意其中的头信息中的 Authorization,就是拿着这个 access_token 的值和类型去认证的。

原理说完了,感受下代码怎么写

自定义异常

今天我们讲的是登录,那么就要有登录失败的异常处理。好的习惯是自定义一些异常类 exception.py:

class LoginException(Exception):
    def __init__(self, error):
        self.error = error

    def __repr__(self):
        return 'Login Fail: {}'.format(self.error)

    __str__ = __repr__

可能有同学会问,咦,用复数形式的 exceptions.py 不是更贴切么?因为 exceptions 已经被 Python 占用了 -. -

Token 类

对 access_token 的操作应该放到一个类下面。 所以我也是用了单独的 ZhihuToken

class ZhihuToken:
    def __init__(self, user_id, uid, access_token, expires_in, token_type,
                 refresh_token, cookie, lock_in=None, unlock_ticket=None):
        self._create_at = time.time()
        self._user_id = uid
        self._uid = user_id
        self._access_token = access_token
        self._expires_in = expires_in
        self._expires_at = self._create_at + self._expires_in
        self._token_type = token_type
        self._refresh_token = refresh_token
        self._cookie = cookie

        # Not used
        self._lock_in = lock_in
        self._unlock_ticket = unlock_ticket

    @classmethod
    def from_file(cls, filename):
        with open(filename) as f:
            return cls.from_dict(json.load(f))

    @staticmethod
    def save_file(filename, data):
        with open(filename, 'w') as f:
            json.dump(data, f)

    @classmethod
    def from_dict(cls, json_dict):
        try:
            return cls(**json_dict)
        except TypeError:
            raise ValueError(
                '"{json_dict}" is NOT a valid zhihu token json.'.format(
                    json_dict=json_dict
                ))

如果已经有 token,用起来是这样的:

In [1]: from client import ZhihuToken, TOKEN_FILE
In [2]: token = ZhihuToken.from_file(TOKEN_FILE)
In [3]: token._token_type
Out[3]: 'bearer'
In [4]: token._expires_at
Out[4]: 1486884430.011306

类方法 from_dict 的返回值是一个该类的实例的玩法现在用的非常普遍。

Client 类

最后就是实现登录功能,我统一放在 ZhihuClient 类中,它做了如下事:

  1. 使用 requests.session 创建一个会话供全局使用。
  2. 构造请求登录的表单 dict。
  3. 登录提交前先确认是否需要输入验证码,如果不需要直接提交,如果需要通过 API 把验证码图片下载到本地,然后等待在终端输入,确认验证成功后再提交。
  4. 提交成功后保存这个访问 token,一段时间内就不用再登录了。

首先看初始化方法:

TOKEN_FILE = 'token.json'  # 事实上应该放在config.py里面


class ZhihuClient:
    def __init__(self, username=None, passwd=None, token_file=TOKEN_FILE):
        self._session = requests.session()
        self._session.verify = False
        self.username = username
        self.passwd = passwd

        if os.path.exists(token_file):
            self._token = ZhihuToken.from_file(token_file)
        else:
            self._login_auth = ZhihuOAuth()
            json_dict = self.login()
            ZhihuToken.save_file(token_file, json_dict)
        self._session.auth = ZhihuOAuth(self._token)

第一次是需要用户名和密码的,之后它们就不是必选的了,只有 token_file 是需要的。需要注意,zhihu-oauth 项目中还包含如下一句:

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

要不然在一些环境中可以看到这个 warn 信息吗,其次是self._session.verify = False也很必要。

如果 token_file 不存在就会触发登录,方法如下:

LOGIN_DATA = {
     'grant_type': 'password',
     'source': 'com.zhihu.ios',
     'client_id': CLIENT_ID
 } 


class ZhihuClient:
    ...
    def login(self):
         data = LOGIN_DATA.copy()
         data['username'] = self.username
         data['password'] = self.passwd
         gen_login_signature(data)

         if self.need_captcha():
             captcha_image = self.get_captcha()
             with open(CAPTCHA_FILE, 'wb') as f:
                 f.write(captcha_image)
             print('Please open {0} for captcha'.format(
                 os.path.abspath(CAPTCHA_FILE)))

             captcha = input('captcha: ')
             os.remove(os.path.abspath(CAPTCHA_FILE))
             res = self._session.post(
                 CAPTCHA_URL,
                 auth=self._login_auth,
                 data={'input_text': captcha}
             )
             try:
                 json_dict = res.json()
                 if 'error' in json_dict:
                     raise LoginException(json_dict['error']['message'])
             except (ValueError, KeyError) as e:
                 raise LoginException('Maybe input wrong captcha value') 

         res = self._session.post(LOGIN_URL, auth=self._login_auth, data=data)
         try:
             json_dict = res.json()
             if 'error' in json_dict:
                 raise LoginException(json_dict['error']['message'])
             self._token = ZhihuToken.from_dict(json_dict)
             return json_dict
         except (ValueError, KeyError) as e:
             raise LoginException(str(e))

首先是构造表单数据,然后判断是否需要验证码(抽出来放在独立的方法中了)。顺便提一下,我习惯的方法的长度标准一般是一屏可以看完,再多了就会把一部分内容剥离开,但是一般也没必要剥离的那么分散,适用就好。

need_captcha 方法也比较简单,总之无论哪一步出错我都抛 LoginException 退出:

def need_captcha(self):
         res = self._session.get(CAPTCHA_URL, auth=self._login_auth)
         try:
             j = res.json()
             return j['show_captcha']
         except KeyError:
             raise LoginException('Show captcha fail!')

gen_login_signature 我并没有去研究,直接抄袭了:

import hashlib
import hmac
import time

from config import APP_SECRET


def gen_login_signature(data):
    data['timestamp'] = str(int(time.time()))

    params = ''.join([
        data['grant_type'],
        data['client_id'],
        data['source'],
        data['timestamp'],
    ])

    data['signature'] = hmac.new(
        APP_SECRET, params.encode('utf-8'), hashlib.sha1).hexdigest()

这样就实现了知乎登录以及拿到 access_token,token 都有了,想干什么就去干吧,对,正好明天周末。

说在最后

最后作为爬虫爱好者我提点意见,爬虫技术拿来分享,我很赞同;爬到数据去做分析,把分析的结果拿来分享我也支持。

但是我严重鄙视把被爬取的网站的数据整理好直接放在百度网盘之类的共享行为!我教大家写爬虫,但请不要涉及工程师的道德底线和法律底线,很 low。

之前我转载过一篇文章,当时也留了百度网盘地址但是里面的内容由于格式错乱并不能直接用,而且为了尊重原作者转载都是全部内容不去改动。但是随着最近这种爬虫文章越来越多,甚至感觉自己也做了帮凶,但以后绝不会再发这样的文章。