crawler

为什么不使用 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

awesome crawlers

无头浏览器的使用

  1. 神器:Puppeteer Recorder,可以录制浏览器操作:https://github.com/checkly/puppeteer-recorder

爬虫方案

其他列表

https://github.com/facert/awesome-spider

电商爬虫

拼多多

  1. https://github.com/onetwo1/pinduoduo

大众点评反爬:

  1. https://www.v2ex.com/t/558529#reply18
  2. https://github.com/Northxw/Dianping

电商爬虫

  1. 电商爬虫系统:京东,当当,一号店,国美爬虫,论坛、新闻、豆瓣爬虫 https://github.com/wanghuafeng/e-business

IT 桔子

  1. https://www.makcyun.top/web_scraping_withpython7.html
  2. https://blog.csdn.net/Michael_Cool/article/details/80098990
  3. https://github.com/shulisiyuan/ITjuziSpider/blob/master/itjuziCompanySpider.py

头条视频

  1. https://github.com/fourbrother/python_toutiaovideo

微博爬虫

  1. https://github.com/jinfagang/weibo_terminater

PornHub 爬虫

  1. https://github.com/xiyouMc/WebHubBot
招聘网站

拉钩爬虫:https://mp.weixin.qq.com/s/uQ_KO84ydPU9qj8nm93gnQ

房产网站:https://github.com/lihansunbai/Fang_Scrapy 爬取58同城、赶集网、链家、安居客、我爱我家网站的房价交易数据。

破解CloudFlare 的反爬措施

https://github.com/Anorov/cloudflare-scrape

反爬技术方案

模拟登录:

https://github.com/xchaoinfo/fuck-login

https://github.com/SpiderClub/smart_login

爬虫框架

https://github.com/yijingping/unicrawler

代理抓取

https://github.com/fate0/getproxy

字体反爬整体方案

https://zhuanlan.zhihu.com/p/37838586

反爬教程:https://github.com/FantasticLBP/Anti-WebSpider

浏览器指纹技术——利用 Header 顺序:

  1. https://cnodejs.org/topic/5060722e01d0b80148172f55
  2. https://gwillem.gitlab.io/2017/05/02/http-header-order-is-important/

深度学习破解点击验证码

  1. https://zhuanlan.zhihu.com/p/34186397
  2. https://github.com/RunningGump/gsxt_captcha
  3. https://github.com/cos120/captcha_crack
  4. CNN 端到端验证码 https://www.jianshu.com/p/08e9d2669b42
  5. Pytorch 验证码识别 https://www.cnblogs.com/king-lps/p/8724361.html
  6. 端到端的不定长验证码识别 https://github.com/airaria/CaptchaRecognition?ts=4
  7. CNN 端到端验证码识别https://github.com/dee1024/pytorch-captcha-recognition
  8. 基于 CNN 的验证码识别 https://github.com/junliangliu/captcha
  9. 变长验证码识别 https://www.jianshu.com/p/25655870b458
  10. https://github.com/cos120/captcha_crack
  11. 生成验证码,可用作训练数据 https://github.com/lepture/captcha
  12. https://github.com/lllcho/CAPTCHA-breaking
  13. https://github.com/yeguixin/captcha_solver

JS 解密与登录

https://github.com/CriseLYJ/awesome-python-login-model https://github.com/OFZFZS/JS-Decryption

中关村 逗游 博客园,37游戏,188游戏中心,立德金融,民投金服,同花顺,金融街,4366, 哔哩哔哩,中国移动 shop99, 连载阅读国美WAP端京东,58同城拉钩起点 滴滴打车 网易博客 手机百度 5173 懒人听书 阿里邮箱 虾米 唯品会 汽车之家 爱卡汽车 酷狗 搜狐微信公众号 ,楚楚街

裁判文书网:https://github.com/sml2h3/mmewmd_crack_for_wenshu

安居客反爬破解:https://www.v2ex.com/t/512956#;

上千家企业新闻网站 https://github.com/NolanZhao/news_feed

数据集

金融公开数据集:https://github.com/PKUJohnson/OpenData/wiki

一个很全的词库

https://github.com/fighting41love/funNLP

涉及内容包括:中英文敏感词、语言检测、中外手机/电话归属地/运营商查询、名字推断性别、手机号抽取、身份证抽取、邮箱抽取、中日文人名库、中文缩写库、拆字词典、词汇情感值、停用词、反动词表、暴恐词表、繁简体转换、英文模拟中文发音、汪峰歌词生成器、职业名称词库、同义词库、反义词库、否定词库、汽车品牌词库、汽车零件词库、连续英文切割、各种中文词向量、公司名字大全、古诗词库、IT词库、财经词库、成语词库、地名词库、历史名人词库、诗词词库、医学词库、饮食词库、法律词库、汽车词库、动物词库、中文聊天语料、中文谣言数据、百度中文问答数据集、句子相似度匹配算法集合、bert资源、文本生成&摘要相关工具、cocoNLP信息抽取工具、国内电话号码正则匹配、清华大学XLORE:中英文跨语言百科知识图谱、清华大学人工智能技术系列报告、自然语言生成、NLU太难了系列、自动对联数据及机器人、用户名黑名单列表、罪名法务名词及分类模型、微信公众号语料、cs224n深度学习自然语言处理课程、中文手写汉字识别、中文自然语言处理 语料/数据集、变量命名神器、分词语料库+代码、任务型对话英文数据集、ASR 语音数据集 + 基于深度学习的中文语音识别系统、笑声检测器、Microsoft多语言数字/单位/如日期时间识别包、中华新华字典数据库及api(包括常用歇后语、成语、词语和汉字)、文档图谱自动生成、SpaCy 中文模型、Common Voice语音识别数据集新版、神经网络关系抽取、基于bert的命名实体识别、关键词(Keyphrase)抽取包pke、基于医疗领域知识图谱的问答系统、基于依存句法与语义角色标注的事件三元组抽取、依存句法分析4万句高质量标注数据、cnocr:用来做中文OCR的Python3包、中文人物关系知识图谱项目、中文nlp竞赛项目及代码汇总、中文字符数据、speech-aligner: 从“人声语音”及其“语言文本”产生音素级别时间对齐标注的工具、AmpliGraph: 知识图谱表示学习(Python)库:知识图谱概念链接预测、Scattertext 文本可视化(python)、语言/知识表示工具:BERT & ERNIE、中文对比英文自然语言处理NLP的区别综述、Synonyms中文近义词工具包、HarvestText领域自适应文本挖掘工具(新词发现-情感分析-实体链接等)、word2word:(Python)方便易用的多语言词-词对集:62种语言/3,564个多语言对、语音识别语料生成工具:从具有音频/字幕的在线视频创建自动语音识别(ASR)语料库。

https://github.com/platonai/pulsar/blob/master/README.zh.md

网页更新与重抓策略

我们知道网页总是会更新的。在大规模的网络爬取中,一个很重要的问题是重抓策略,也就是在什么时候去重新访问同一个网页已获得更新。要获得这个问题的解,需要满足如下两个条件:

  1. 尽可能地少访问,以减少自身和对方站点的资源占用
  2. 尽可能快的更新,以便获得最新结果

这两个条件几乎是对立的,所以我们必须找到一种算法,并获得一个尽可能优的折衷。

可以使用泊松过程:https://stackoverflow.com/questions/10331738/strategy-for-how-to-crawl-index-frequently-updated-webpages

https://en.wikipedia.org/wiki/Web_crawler
https://intoli.com/blog/aopic-algorithm/

为什么不使用 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 源码解读

知乎移动端接口分析

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

注册和登录抓包

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

如何破解被 JS 加密的数据

由于网页和JavaScript都是明文的,导致很多API接口都直接暴露在爬虫的眼里,所以好多
网站选择使用混淆后的 JavaScript 来加密接口。其中有分为两大类:

  1. 通过 JavaScript 计算一个参数或者 Cookie 作为接口的签名验证
  2. 返回的数据是加密的,需要使用 JavaScript 解密

不过总的来说,这两种加密的破解思路都是一样的。

  1. 找到相关的网络请求。如果找不到,清空缓存,尝试触发
  2. 打断点找到相关代码,可以是 ajax 断点或者 js 断点。或者直接看网络请求的
    initiator
  3. 逐层分析,找到加密函数
  4. 使用 node 执行js代码获得数据

具体步骤

有空了再写。。

参考:

  1. 中国天气质量网返回结果加密的破解
  2. 破解 Google 翻译的token
  3. JavaScript 生成 Cookie
  4. 常见加密算法

爬虫 IP 封禁与反封禁

反爬虫的核心在于区分开正常用户访问和恶意爬虫用户。来源 IP 是访问很重要的一个特征,我们可以从来源 IP 的角度来做出不少反爬虫策略。

  • 是否是代理IP
  • 是否是民用IP
  • IP 地理信息

一般来说,大规模的爬虫我们都会放到服务器上去跑,搭建代理集群也会在服务器上,而正常用户的IP地址则来自家用IP范围内。这就给反爬虫的一方提供了便利,对于来自数据中心的请求可以直接限制访问甚至直接屏蔽掉,而对于家用的IP地址则宽容一些。

下面我们来看几个实例

直接爬取网站

一般正常用户的页面访问量很小,如果发现某个 IP 的访问量特别大,那么肯定是爬虫,直接封禁即可,或者每次都需要输入验证码访问。

IP 被封禁后一般不会被解封,或者需要很长时间,这时候只有两种思路,要么降低频率,更改自己的行为特征,避免被封,要么更换 IP。一般来说,不管怎样更改自己的行为,访问量还是很难降下来的,这时候只能换一个 IP 继续爬。

使用代理网站提供的代理IP

一些黑客会使用端口扫描器扫描互联网上的开放代理,然后免费或者付费提供给其他用户使用,比如下面这些网站:

免费代理

但是这些网站的代理中能直接使用的可能不到10%,而且失效时间很短。所以要使用这些代理 IP,需要首先爬取这些网站,然后随取随用。

利用 ADSL 服务器更换 IP

网上有一些小的厂商代理了各地运营商的服务,搭建了一些小的服务器,一般内存只有 512M,而硬盘只有 8G,但是好处是通过 ADSL 上网,因此可以随时更换 IP。比如笔者搭建的这个动态代理:

ADSL

每三十分钟更换一次 IP,而这些服务器也很便宜,在 100-200 每月,所以大可以搭建一个集群,这样基本上一个 IP 被封之前也基本被换掉了。

要封禁这种用户也很简单,可以看出虽然 IP 在更换,但是基本上还是在一个 B 段之内,一个 B 段也就6w个用户,直接封了就行了

利用数据中心提供的更换 IP 接口来

有些爬虫会利用阿里云或者AWS的弹性 IP 来爬数据,反爬虫的第一步可以把阿里云的 IP 都屏蔽掉,正常用户一般是不会用这些 IP 来访问的。

附录

阿里云的出口 IP 列表:

deny 42.96.128.0/17;
deny 42.120.0.0/16;
deny 42.121.0.0/16;
deny 42.156.128.0/17;
deny 110.75.0.0/16;
deny 110.76.0.0/19;
deny 110.76.32.0/20;
deny 110.76.48.0/20;
deny 110.173.192.0/20;
deny 110.173.208.0/20;
deny 112.74.0.0/16;
deny 112.124.0.0/16;
deny 112.127.0.0/16;
deny 114.215.0.0/16;
deny 115.28.0.0/16;
deny 115.29.0.0/16;
deny 115.124.16.0/22;
deny 115.124.20.0/22;
deny 115.124.24.0/21;
deny 119.38.208.0/21;
deny 119.38.216.0/21;
deny 119.42.224.0/20;
deny 119.42.242.0/23;
deny 119.42.244.0/22;
deny 120.24.0.0/14;
deny 120.24.0.0/16;
deny 120.25.0.0/18;
deny 120.25.64.0/19;
deny 120.25.96.0/21;
deny 120.25.108.0/24;
deny 120.25.110.0/24;
deny 120.25.111.0/24;
deny 121.0.16.0/21;
deny 121.0.24.0/22;
deny 121.0.28.0/22;
deny 121.40.0.0/14;
deny 121.42.0.0/18;
deny 121.42.0.0/24;
deny 121.42.64.0/18;
deny 121.42.128.0/18;
deny 121.42.192.0/19;
deny 121.42.224.0/19;
deny 121.196.0.0/16;
deny 121.197.0.0/16;
deny 121.198.0.0/16;
deny 121.199.0.0/16;
deny 140.205.0.0/16;
deny 203.209.250.0/23;
deny 218.244.128.0/19;
deny 223.4.0.0/16;
deny 223.5.0.0/16;
deny 223.5.5.0/24;
deny 223.6.0.0/16;
deny 223.6.6.0/24;
deny 223.7.0.0/16;
101.200.0.0/15 
101.37.0.0/16 
101.37.0.0/17 
101.37.0.0/24 
101.37.128.0/17 
103.52.196.0/22 
103.52.196.0/23 
103.52.196.0/24 
103.52.198.0/23 
106.11.0.0/16 
106.11.0.0/17 
106.11.0.0/18 
106.11.1.0/24 
106.11.128.0/17 
106.11.32.0/22 
106.11.36.0/22 
106.11.48.0/21 
106.11.56.0/21 
106.11.64.0/19 
110.173.192.0/20 
110.173.196.0/24 
110.173.208.0/20 
110.75.0.0/16 
110.75.236.0/22 
110.75.239.0/24 
110.75.240.0/20 
110.75.242.0/24 
110.75.243.0/24 
110.75.244.0/22 
110.76.0.0/19 
110.76.21.0/24 
110.76.32.0/20 
110.76.48.0/20 
112.124.0.0/16 
112.125.0.0/16 
112.126.0.0/16 
112.127.0.0/16 
112.74.0.0/16 
112.74.0.0/17 
112.74.116.0/22 
112.74.120.0/22 
112.74.128.0/17 
112.74.32.0/19 
112.74.64.0/22 
112.74.68.0/22 
114.215.0.0/16 
114.55.0.0/16 
114.55.0.0/17 
114.55.128.0/17 
115.124.16.0/22 
115.124.20.0/22 
115.124.24.0/21 
115.28.0.0/16 
115.29.0.0/16 
118.190.0.0/16 
118.190.0.0/17 
118.190.0.0/24 
118.190.128.0/17 
118.31.0.0/16 
118.31.0.0/17 
118.31.0.0/24 
118.31.128.0/17 
119.38.208.0/21 
119.38.216.0/21 
119.38.219.0/24 
119.42.224.0/20 
119.42.242.0/23 
119.42.244.0/22 
119.42.248.0/21 
120.24.0.0/14 
120.24.0.0/15 
120.25.0.0/18 
120.25.104.0/22 
120.25.108.0/24 
120.25.110.0/24 
120.25.111.0/24 
120.25.112.0/23 
120.25.115.0/24 
120.25.136.0/22 
120.25.64.0/19 
120.25.96.0/21 
120.27.0.0/17 
120.27.128.0/17 
120.27.128.0/18 
120.27.192.0/18 
120.55.0.0/16 
120.76.0.0/15 
120.76.0.0/16 
120.77.0.0/16 
120.78.0.0/15 
121.0.16.0/21 
121.0.24.0/22 
121.0.28.0/22 
121.196.0.0/16 
121.197.0.0/16 
121.198.0.0/16 
121.199.0.0/16 
121.40.0.0/14 
121.42.0.0/18 
121.42.0.0/24 
121.42.128.0/18 
121.42.17.0/24 
121.42.192.0/19 
121.42.224.0/19 
121.42.64.0/18 
123.56.0.0/15 
123.56.0.0/16 
123.57.0.0/16 
139.129.0.0/16 
139.129.0.0/17 
139.129.128.0/17 
139.196.0.0/16 
139.196.0.0/17 
139.196.128.0/17 
139.224.0.0/16 
139.224.0.0/17 
139.224.128.0/17 
140.205.0.0/16 
140.205.128.0/18 
140.205.192.0/18 
140.205.32.0/19 
140.205.76.0/24 
182.92.0.0/16 
203.107.0.0/24 
203.107.1.0/24 
203.209.224.0/19 
218.244.128.0/19 
223.4.0.0/16 
223.5.0.0/16 
223.5.5.0/24 
223.6.0.0/16 
223.6.6.0/24 
223.7.0.0/16 
39.100.0.0/14 
39.104.0.0/14 
39.104.0.0/15 
39.104.0.0/24 
39.106.0.0/15 
39.108.0.0/16 
39.108.0.0/17 
39.108.0.0/24 
39.108.128.0/17 
39.96.0.0/13 
39.96.0.0/14 
39.96.0.0/24 
42.120.0.0/16 
42.121.0.0/16 
42.156.128.0/17 
42.96.128.0/17 
45.113.40.0/22 
45.113.40.0/23 
45.113.40.0/24 
45.113.42.0/23 
47.92.0.0/14 
47.92.0.0/15 
47.92.0.0/24 
47.94.0.0/15

Go 语言和爬虫

爬虫的算法

广度遍历

如果把每一个页面看做一个节点,把每个链接看做一个有向边,那么网页之间就构成了一个有向图。爬虫的核心就是对这个图做一个广度优先的遍历:

func breadthFirst(visit func(item, string) []string, worklist []string) {
    seen := make(map[string]bool)
    for len(worklist) > 0 {
        items := worklist
        worklist = nil
        for _, items := range items {
            if !seen[item] {
                seen[item] = true
                worklist = append(worklist, visit(item)...)
            }
        }
    }
}

终止条件

如果我们面对的是一个有限的图,那么用广度遍历一定可以停下来。但是对于互联网来说,甚至于对于某个网站来说,页面的数量都可能是无限的,或者说没必要爬遍所有页面。那么需要考虑以下几个限制条件:

  • 边界限制,比如说限定下爬去的域名
  • 深度,比如说限定下爬取的深度
  • 并发,比如说开多少个goroutine?以及如何控制并发
  • 如何终止,终止条件是什么,限制抓取的深度还是什么?
  • 等待所有进程终止,当程序退出的时候,有没有 wait 子过程退出

抽取数据

使用 CSS 定位元素,而不是 XPath

之前还在用 Python 写爬虫的时候喜欢用 XPath,主要是选择路径比 CSS 表达式看起来更清晰,而且 Python 有一个强大的 lxml 库,对于 xpath 的操作非常便捷。不过也有些缺点,xpath 的坏处就是没有办法按照类选择,而只能按照 class 当做一个属性来选择。而现在的布局之类的好多都是按照类来的,所以可能还是使用 CSS 表达式比较好。举个例子:

<div class="title col-sm-6">Hello World</div>

比如说网站采用了上面的标签来表示标题,其中的col-sm-6可能是用于页面布局的一个类,很有可能经常改变,所以我们想要按照 title 这个属性来定位这个元素,如果使用 xpath 的话,需要这样写:

//div[contains(concat(" ", normalize-space(@class), " "), " title ")]

而 CSS 天生就是为了布局而生的,所以要选择这个元素,直接这样就可以了:

.title

当然 CSS 也有一些不方便的时候,比如 XPath 使用 //nav/span[2] 就能表达清楚的逻辑,CSS 需要使用 nav>span:nth-child(2)。略显长,但是还好不像 XPath 表达类(class)的时候那么 trick。

另外,XPath 不光可以选择元素,还可以选择属性,比如 //a/@href,可以直接拿到a 的链接,而CSS则只能选择标签。

goquery

在 Go 语言中,可以使用 goquery 来选取元素,他实现了类似于 jQuery 的语法。

goquery 提供了两个类型,Document 和 Selector,主要通过这两个对象的方法来选择元素。

type Document struct {
    *Selection
    Url *url.URL
    rootNode *html.Node // 文档的根节点
}

Document 内嵌了 Selection,因此可以直接使用Selection 的方法。

type Selection struct {
    Nodes []*html.Node
    document *Document
    prevSel *Selection
}

其中 Selection 的不少方法都是和 jQuery 中类似的,再次不再赘述,只列出来可能和抓取相关的一些函数。

  1. 生成文档

    1. NewDocumentFromNode(root *html.Node) *Document: 传入 *html.Node 对象,也就是根节点。
    2. NewDocument(url string) (*Document, error): 传入 URL,内部用 http.Get 获取网页。
    3. NewDocumentFromReader(r io.Reader) (*Document, error): 传入 io.Reader,内部从 reader 中读取内容并解析。
    4. NewDocumentFromResponse(res *http.Response) (*Document, error): 传入 HTTP 响应,内部拿到 res.Body(实现了 io.Reader) 后的处理方式类似 NewDocumentFromReader.
  2. 查找节点

    1. Find() 根据 CSS 查找节点
  3. 循环遍历选择的节点

    1. Each(f func(int, *Selection)) *Selection: 其中函数 f 的第一个参数是当前的下标,第二个参数是当前的节点
    2. EachWithBreak(f func(int, *Selection) bool) *Selection: 和 Each 类似,增加了中途跳出循环的能力,当 f 返回 false 时结束迭代
    3. Map(f func(int, *Selection) string) (result []string): f 的参数与上面一样,返回一个 string 类型,最终返回 []string.
  4. 获取节点的属性或者内容

    1. Attr(): 获得某个属性的值
    2. Html(): 获得当前节点的 html
    3. Length():
    4. Text():

(未完待续)

ref

  1. http://liyangliang.me/posts/2016/03/zhihu-go-insight-parsing-html-with-goquery/
  2. http://blog.studygolang.com/2015/04/go-jquery-goquery/