howdoi 源码阅读与分析

howdoi – 一款通过命令行帮你从 stackoverflow 中寻找答案的工具,其源代码部分不足 300 行。同时,这份代码被 The Hitchhiker’s Guide to Python! 推荐为适合新手源码阅读的代码。因此,在读完源码后,我分享一下我对这份代码的理解。

这里采用的版本是 tree 中标号为 d84afdee60 的那一版

实现思路

简单的概括:

获取搜索关键词 –> 通过爬虫找到 stackoverflow 上的答案(html 格式) –> 对 html 进行解析拿到答案

获取关键词

其中 关键词 必须从终端获取,这一步可以通过 Python 自带的包 argparse 实现。

在代码的第 153 行,args['query'] = ' '.join(args['query']).replace('?', ''), 这里作者将关键词中的问号去掉了。刚开始误以为这个处理是怕将问号放到 url 后,问号后面的字符串会变为 url 中查询的参数,从而没有达到使用者的意图。事实上,我的这个想法是错误的,因为之后的代码中,作者会将查询的字符串转义了。我的第二个猜测认为问号会影响 google 查询的结果,因为 ? 符号是 google 搜索的指令之一,比如 搜索 c?lorclor 的结果几乎是完全不同的。

爬虫

而爬虫部分,则使用包 requests ,其中 url 采用的是 google 搜索的 url, 'https://www.google.com/search?q=site:{0}%20{1}'。其中 0 位对应搜索的目标网站,而 1 位代表搜索的内容。比如 'https://www.google.com/search?q=site:stackoverflow.com%20python%20async' 代表在 stackoverflow 上搜索有关 python async 的内容,相当于你在 google 的搜索框里输入了 site:stackoverflow python async。其中 site 是 google 搜索的一个指令,其它指令还包括 filetype (制定文件类型)等。

而 1 的位置填的搜索的内容正是命令行中输入的内容。为了安全起见,最好将其转义,如代码中 95 行所示 result = _get_result(SEARCH_URL.format(URL, url_quote(query)))。假如 query = 'foo bar', 那么转义后 query = 'foo%20bar'

解析 html

采用的工具是 pyquery,它可以让使用者像使用 jquery 一样解析 html 代码。

第一次拿到的 html 是 google 搜索的结果,而不是 stackoverflow 的页面,所以要先拿到有答案的 stackoverflow 的页面。函数 _is_question 帮助程序识别超链接是否是 stackoverflow 的链接。

如果是 stackoverflow 的链接,那么接下来有一个分支 – 第 151, 152 行。

  1. 用户只需要 url。那么就此打住,将 url 返回即可。
  2. 如果用户需要答案。就对这个链接做一次请求,这一次拿到的 html 就是 stackoverflow 有提问和回答的页面了。为了提高拿到的答案的可靠型,在 153 行,page = _get_result(link + '?answertab=votes') 使得 stackoverflow 页面返回的结果是根据答案的支持数从高到低排序的。

拿到页面后,再对 html 分析,这里又有一个分支。如果用户只要代码,拿到 pre 标签内或 code 标签内的内容返回,否则把答案的文本全部返回。

其它

以下几个部分即使删去,也并不也影响程序的主要功能。但加上的话可以很好的改善用户体验。

代理

对应函数 get_proxies。其中作者还为没有用 http 开头的网址加了 http,比如 {'http': 'localhost:1080'} 会转化为 {'http': 'http://localhost:1080'}

缓存

如果开启缓存而且搜索次数多了,缓存可以很好地改善用户体验。作者的实现采用了 requests_cache。使用也挺简单的,不多说了,可以参考文档

颜色输出

这也是改善用户体验的一个途径。作者采用的是 pygments 。用 pygments 提供的 lexer 对字符串解析并加上颜色。

代码风格

这里讲一些我从这份源码中比较有启发的代码风格

私有函数

Python 中并没有严格意义上的 私有函数,一般来讲,名字以但下划线开头的即为 不推荐调用 的函数,也可以认为属于私有函数。而观察这个项目以前的代码,可以发现最早并没有私有函数。私有函数的出现起于 Pull Request: PEP 8 conventions #132。个人认为区分私有和公有的区分对于使用者的学习还是有帮助的,尤其是文档不多或者使用者比较着急使用的情况下,使用者可以直接看公有函数。另外,公有函数也警告使用者不要随意调用,因为有些私有函数的随意调用可能会造成一些对项目比较危险的结果。

函数的顺序

不知是无意还是有意,函数的顺序根据调用的顺序逆序排列。比如我们从 command_line_runner 看起,这个函数第一个出现的调用是 get_parser,而源码中 command_line_runner 的上一个函数就是 get_parser。继续往下看,则可以看到 _clear_cache, _enable_cache。而这个顺序刚好是你从文件尾往文件头看得顺序。也就是说,每次我看到一个函数调用时,我肯定往上翻就行了,而且越先看到的越先出现,这给阅读源码带来了很大的便利。

常量

我发现这里的常量包括了作为模板的字符串,如 SEARCH_URL = 'https://www.google.com/search?q=site:{0}%20{1}'。仔细想想,我好像以前写常量都是固定的字符串或数字,这种模板型的字符串常量我好像一直没用过。

小细节

URL = os.getenv('HOWDOI_URL') or 'stackoverflow.com'

注意这里用的 stackoverflow 地址是 stackoverflow.com 而不是 www.stackoverflow.com。这是有区别的。使用前者搜索,出来的 stackoverflow 相关链接大多是 stackoverflow.com/problems/(\d+)/ 形式,而使用后者,大多出现的是 www.stackoverflow.com/a/(\d+)/ 形式。前者的形式相对后者的形式更容易判断一个链接是不是 stackoverflow 的问题链接。

后记

howdoi 的点子很好,而且源码确实比较易懂。读完源码之后,建议可以自己试着实现一个类似的或用其它语言复刻一个。毕竟,有些问题是只有开发时才能想到的。